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 {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.appBackgroundPrimary.ignoresSafeArea()
|
WarmGradientBackground()
|
||||||
contentStateView
|
contentStateView
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ struct ContractorsListView: View {
|
|||||||
@State private var showSpecialtyFilter = false
|
@State private var showSpecialtyFilter = false
|
||||||
@State private var showingUpgradePrompt = false
|
@State private var showingUpgradePrompt = false
|
||||||
|
|
||||||
// Lookups from DataManagerObservable
|
|
||||||
private var contractorSpecialties: [ContractorSpecialty] { dataManager.contractorSpecialties }
|
private var contractorSpecialties: [ContractorSpecialty] { dataManager.contractorSpecialties }
|
||||||
|
|
||||||
var specialties: [String] {
|
var specialties: [String] {
|
||||||
@@ -23,7 +22,6 @@ struct ContractorsListView: View {
|
|||||||
viewModel.contractors
|
viewModel.contractors
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client-side filtering since backend doesn't support search/filter params
|
|
||||||
var filteredContractors: [ContractorSummary] {
|
var filteredContractors: [ContractorSummary] {
|
||||||
contractors.filter { contractor in
|
contractors.filter { contractor in
|
||||||
let matchesSearch = searchText.isEmpty ||
|
let matchesSearch = searchText.isEmpty ||
|
||||||
@@ -36,59 +34,58 @@ struct ContractorsListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if upgrade screen should be shown (disables add button)
|
|
||||||
private var shouldShowUpgrade: Bool {
|
private var shouldShowUpgrade: Bool {
|
||||||
subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors")
|
subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors")
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.appBackgroundPrimary.ignoresSafeArea()
|
WarmGradientBackground()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Search Bar
|
// Search Bar
|
||||||
SearchBar(text: $searchText, placeholder: L10n.Contractors.searchPlaceholder)
|
OrganicSearchBar(text: $searchText, placeholder: L10n.Contractors.searchPlaceholder)
|
||||||
.padding(.horizontal, AppSpacing.md)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, AppSpacing.sm)
|
.padding(.top, 8)
|
||||||
|
|
||||||
// Active Filters
|
// Active Filters
|
||||||
if showFavoritesOnly || selectedSpecialty != nil {
|
if showFavoritesOnly || selectedSpecialty != nil {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: AppSpacing.xs) {
|
HStack(spacing: 8) {
|
||||||
if showFavoritesOnly {
|
if showFavoritesOnly {
|
||||||
FilterChip(
|
OrganicFilterChip(
|
||||||
title: L10n.Contractors.favorites,
|
title: L10n.Contractors.favorites,
|
||||||
icon: "star.fill",
|
icon: "star.fill",
|
||||||
onRemove: { showFavoritesOnly = false }
|
onRemove: { showFavoritesOnly = false }
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if let specialty = selectedSpecialty {
|
|
||||||
FilterChip(
|
|
||||||
title: specialty,
|
|
||||||
onRemove: { selectedSpecialty = nil }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.md)
|
|
||||||
}
|
|
||||||
.padding(.vertical, AppSpacing.xs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content - use filteredContractors for client-side filtering
|
if let specialty = selectedSpecialty {
|
||||||
|
OrganicFilterChip(
|
||||||
|
title: specialty,
|
||||||
|
onRemove: { selectedSpecialty = nil }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
ListAsyncContentView(
|
ListAsyncContentView(
|
||||||
items: filteredContractors,
|
items: filteredContractors,
|
||||||
isLoading: viewModel.isLoading,
|
isLoading: viewModel.isLoading,
|
||||||
errorMessage: viewModel.errorMessage,
|
errorMessage: viewModel.errorMessage,
|
||||||
content: { contractorList in
|
content: { contractorList in
|
||||||
ContractorsContent(
|
OrganicContractorsContent(
|
||||||
contractors: contractorList,
|
contractors: contractorList,
|
||||||
onToggleFavorite: toggleFavorite
|
onToggleFavorite: toggleFavorite
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
emptyContent: {
|
emptyContent: {
|
||||||
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
|
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
|
||||||
EmptyContractorsView(
|
OrganicEmptyContractorsView(
|
||||||
hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
|
hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -107,82 +104,77 @@ struct ContractorsListView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(L10n.Contractors.title)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.toolbar {
|
||||||
.toolbar {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
HStack(spacing: 12) {
|
||||||
HStack(spacing: AppSpacing.sm) {
|
// Favorites Filter
|
||||||
// Favorites Filter (client-side, no API call needed)
|
Button(action: {
|
||||||
Button(action: {
|
showFavoritesOnly.toggle()
|
||||||
showFavoritesOnly.toggle()
|
}) {
|
||||||
}) {
|
Image(systemName: showFavoritesOnly ? "star.fill" : "star")
|
||||||
Image(systemName: showFavoritesOnly ? "star.fill" : "star")
|
.font(.system(size: 16, weight: .medium))
|
||||||
.foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary)
|
.foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary)
|
||||||
}
|
|
||||||
|
|
||||||
// Specialty Filter (client-side, no API call needed)
|
|
||||||
Menu {
|
|
||||||
Button(action: {
|
|
||||||
selectedSpecialty = nil
|
|
||||||
}) {
|
|
||||||
Label(L10n.Contractors.allSpecialties, systemImage: selectedSpecialty == nil ? "checkmark" : "")
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
ForEach(specialties, id: \.self) { specialty in
|
|
||||||
Button(action: {
|
|
||||||
selectedSpecialty = specialty
|
|
||||||
}) {
|
|
||||||
Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
|
||||||
.foregroundColor(selectedSpecialty != nil ? Color.appPrimary : Color.appTextSecondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Button (disabled when showing upgrade screen)
|
|
||||||
Button(action: {
|
|
||||||
let currentCount = viewModel.contractors.count
|
|
||||||
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") {
|
|
||||||
// Track paywall shown
|
|
||||||
PostHogAnalytics.shared.capture(AnalyticsEvents.contractorPaywallShown, properties: ["current_count": currentCount])
|
|
||||||
showingUpgradePrompt = true
|
|
||||||
} else {
|
|
||||||
showingAddSheet = true
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Image(systemName: "plus.circle.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundColor(Color.appPrimary)
|
|
||||||
}
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Specialty Filter
|
||||||
|
Menu {
|
||||||
|
Button(action: {
|
||||||
|
selectedSpecialty = nil
|
||||||
|
}) {
|
||||||
|
Label(L10n.Contractors.allSpecialties, systemImage: selectedSpecialty == nil ? "checkmark" : "")
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ForEach(specialties, id: \.self) { specialty in
|
||||||
|
Button(action: {
|
||||||
|
selectedSpecialty = specialty
|
||||||
|
}) {
|
||||||
|
Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(selectedSpecialty != nil ? Color.appPrimary : Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Button
|
||||||
|
Button(action: {
|
||||||
|
let currentCount = viewModel.contractors.count
|
||||||
|
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") {
|
||||||
|
PostHogAnalytics.shared.capture(AnalyticsEvents.contractorPaywallShown, properties: ["current_count": currentCount])
|
||||||
|
showingUpgradePrompt = true
|
||||||
|
} else {
|
||||||
|
showingAddSheet = true
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
OrganicToolbarButton(systemName: "plus", isPrimary: true)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAddSheet) {
|
}
|
||||||
ContractorFormSheet(
|
.sheet(isPresented: $showingAddSheet) {
|
||||||
contractor: nil,
|
ContractorFormSheet(
|
||||||
onSave: {
|
contractor: nil,
|
||||||
loadContractors()
|
onSave: {
|
||||||
}
|
loadContractors()
|
||||||
)
|
}
|
||||||
.presentationDetents([.large])
|
)
|
||||||
}
|
.presentationDetents([.large])
|
||||||
.sheet(isPresented: $showingUpgradePrompt) {
|
}
|
||||||
UpgradePromptView(triggerKey: "view_contractors", isPresented: $showingUpgradePrompt)
|
.sheet(isPresented: $showingUpgradePrompt) {
|
||||||
}
|
UpgradePromptView(triggerKey: "view_contractors", isPresented: $showingUpgradePrompt)
|
||||||
.onAppear {
|
}
|
||||||
PostHogAnalytics.shared.screen(AnalyticsEvents.contractorScreenShown)
|
.onAppear {
|
||||||
loadContractors()
|
PostHogAnalytics.shared.screen(AnalyticsEvents.contractorScreenShown)
|
||||||
}
|
loadContractors()
|
||||||
// No need for onChange on searchText - filtering is client-side
|
}
|
||||||
// Contractor specialties are loaded from DataManagerObservable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadContractors(forceRefresh: Bool = false) {
|
private func loadContractors(forceRefresh: Bool = false) {
|
||||||
// Load all contractors, filtering is done client-side
|
|
||||||
viewModel.loadContractors(forceRefresh: forceRefresh)
|
viewModel.loadContractors(forceRefresh: forceRefresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,73 +187,82 @@ struct ContractorsListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Search Bar
|
// MARK: - Organic Search Bar
|
||||||
struct SearchBar: View {
|
|
||||||
|
private struct OrganicSearchBar: View {
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
var placeholder: String
|
var placeholder: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: AppSpacing.sm) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "magnifyingglass")
|
ZStack {
|
||||||
.foregroundColor(Color.appTextSecondary)
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
TextField(placeholder, text: $text)
|
TextField(placeholder, text: $text)
|
||||||
.font(.body)
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
|
||||||
if !text.isEmpty {
|
if !text.isEmpty {
|
||||||
Button(action: { text = "" }) {
|
Button(action: { text = "" }) {
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.system(size: 18))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(AppSpacing.sm)
|
.padding(14)
|
||||||
.background(Color.appBackgroundSecondary)
|
.background(Color.appBackgroundSecondary)
|
||||||
.cornerRadius(AppRadius.md)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y)
|
.naturalShadow(.subtle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Filter Chip
|
// MARK: - Organic Filter Chip
|
||||||
struct FilterChip: View {
|
|
||||||
|
private struct OrganicFilterChip: View {
|
||||||
let title: String
|
let title: String
|
||||||
var icon: String? = nil
|
var icon: String? = nil
|
||||||
let onRemove: () -> Void
|
let onRemove: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: AppSpacing.xxs) {
|
HStack(spacing: 6) {
|
||||||
if let icon = icon {
|
if let icon = icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .semibold))
|
||||||
}
|
}
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.footnote.weight(.medium))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
|
||||||
Button(action: onRemove) {
|
Button(action: onRemove) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.caption2)
|
.font(.system(size: 10, weight: .bold))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.sm)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, AppSpacing.xxs)
|
.padding(.vertical, 8)
|
||||||
.background(Color.appPrimary.opacity(0.1))
|
.background(Color.appPrimary.opacity(0.15))
|
||||||
.foregroundColor(Color.appPrimary)
|
.foregroundColor(Color.appPrimary)
|
||||||
.cornerRadius(AppRadius.full)
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Contractors Content
|
// MARK: - Organic Contractors Content
|
||||||
|
|
||||||
private struct ContractorsContent: View {
|
private struct OrganicContractorsContent: View {
|
||||||
let contractors: [ContractorSummary]
|
let contractors: [ContractorSummary]
|
||||||
let onToggleFavorite: (Int32) -> Void
|
let onToggleFavorite: (Int32) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView(showsIndicators: false) {
|
||||||
LazyVStack(spacing: AppSpacing.sm) {
|
LazyVStack(spacing: 12) {
|
||||||
ForEach(contractors, id: \.id) { contractor in
|
ForEach(contractors, id: \.id) { contractor in
|
||||||
NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) {
|
NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) {
|
||||||
ContractorCard(
|
OrganicContractorCard(
|
||||||
contractor: contractor,
|
contractor: contractor,
|
||||||
onToggleFavorite: {
|
onToggleFavorite: {
|
||||||
onToggleFavorite(contractor.id)
|
onToggleFavorite(contractor.id)
|
||||||
@@ -271,8 +272,8 @@ private struct ContractorsContent: View {
|
|||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(AppSpacing.md)
|
.padding(16)
|
||||||
.padding(.bottom, AppSpacing.xxxl)
|
.padding(.bottom, 40)
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom) {
|
.safeAreaInset(edge: .bottom) {
|
||||||
Color.clear.frame(height: 0)
|
Color.clear.frame(height: 0)
|
||||||
@@ -280,32 +281,189 @@ private struct ContractorsContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Empty State
|
// MARK: - Organic Contractor Card
|
||||||
struct EmptyContractorsView: View {
|
|
||||||
let hasFilters: Bool
|
private struct OrganicContractorCard: View {
|
||||||
|
let contractor: ContractorSummary
|
||||||
|
let onToggleFavorite: () -> Void
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: AppSpacing.md) {
|
HStack(spacing: 14) {
|
||||||
Image(systemName: "person.badge.plus")
|
// Avatar
|
||||||
.font(.system(size: 64))
|
ZStack {
|
||||||
.foregroundColor(Color.appTextSecondary.opacity(0.7))
|
Circle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
|
||||||
Text(hasFilters ? L10n.Contractors.emptyFiltered : L10n.Contractors.emptyTitle)
|
Text(String(contractor.name.prefix(1)).uppercased())
|
||||||
.font(.title3.weight(.semibold))
|
.font(.system(size: 20, weight: .bold))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
|
||||||
if !hasFilters {
|
|
||||||
Text(L10n.Contractors.emptyNoFilters)
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundColor(Color.appTextSecondary.opacity(0.7))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(contractor.name)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
if let company = contractor.company, !company.isEmpty {
|
||||||
|
Text(company)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !contractor.specialties.isEmpty {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ForEach(contractor.specialties.prefix(2), id: \.id) { specialty in
|
||||||
|
Text(specialty.name)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.appPrimary.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
if contractor.specialties.count > 2 {
|
||||||
|
Text("+\(contractor.specialties.count - 2)")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Favorite Button
|
||||||
|
Button(action: onToggleFavorite) {
|
||||||
|
Image(systemName: contractor.isFavorite ? "star.fill" : "star")
|
||||||
|
.font(.system(size: 18, weight: .medium))
|
||||||
|
.foregroundColor(contractor.isFavorite ? Color.appAccent : Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appTextSecondary.opacity(0.5))
|
||||||
}
|
}
|
||||||
.padding(AppSpacing.xl)
|
.padding(16)
|
||||||
|
.background(
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
|
||||||
|
GeometryReader { geo in
|
||||||
|
OrganicBlobShape(variation: 1)
|
||||||
|
.fill(Color.appPrimary.opacity(colorScheme == .dark ? 0.04 : 0.02))
|
||||||
|
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.8)
|
||||||
|
.offset(x: geo.size.width * 0.6, y: 0)
|
||||||
|
.blur(radius: 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
GrainTexture(opacity: 0.01)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||||
|
.naturalShadow(.medium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ContractorsListView_Previews: PreviewProvider {
|
// MARK: - Organic Toolbar Button
|
||||||
static var previews: some View {
|
|
||||||
|
private struct OrganicToolbarButton: View {
|
||||||
|
let systemName: String
|
||||||
|
var isPrimary: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
if isPrimary {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
|
||||||
|
Image(systemName: systemName)
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
} else {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
|
||||||
|
Image(systemName: systemName)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Organic Empty Contractors View
|
||||||
|
|
||||||
|
private struct OrganicEmptyContractorsView: View {
|
||||||
|
let hasFilters: Bool
|
||||||
|
@State private var isAnimating = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(0.15),
|
||||||
|
Color.appPrimary.opacity(0.05),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 60
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
|
.animation(
|
||||||
|
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true),
|
||||||
|
value: isAnimating
|
||||||
|
)
|
||||||
|
|
||||||
|
Image(systemName: "person.badge.plus")
|
||||||
|
.font(.system(size: 44, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text(hasFilters ? L10n.Contractors.emptyFiltered : L10n.Contractors.emptyTitle)
|
||||||
|
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
|
if !hasFilters {
|
||||||
|
Text(L10n.Contractors.emptyNoFilters)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.onAppear {
|
||||||
|
isAnimating = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
ContractorsListView()
|
ContractorsListView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,20 +6,26 @@ struct EmptyStateView: View {
|
|||||||
let message: String
|
let message: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: AppSpacing.md) {
|
VStack(spacing: OrganicSpacing.cozy) {
|
||||||
Image(systemName: icon)
|
ZStack {
|
||||||
.font(.system(size: 64))
|
Circle()
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.fill(Color.appPrimary.opacity(0.08))
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 44, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary.opacity(0.6))
|
||||||
|
}
|
||||||
|
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.title3.weight(.semibold))
|
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.body)
|
.font(.system(size: 15, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary.opacity(0.7))
|
.foregroundColor(Color.appTextSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
.padding(AppSpacing.lg)
|
.padding(OrganicSpacing.comfortable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ struct DocumentDetailView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func documentDetailContent(document: Document) -> some View {
|
private func documentDetailContent(document: Document) -> some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
// Status Badge (for warranties)
|
// Status Badge (for warranties)
|
||||||
if document.documentType == "warranty" {
|
if document.documentType == "warranty" {
|
||||||
warrantyStatusCard(document: document)
|
warrantyStatusCard(document: document)
|
||||||
@@ -212,9 +212,9 @@ struct DocumentDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.systemBackground))
|
.background(Color.appBackgroundSecondary)
|
||||||
.cornerRadius(12)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.shadow(radius: 2)
|
.naturalShadow(.subtle)
|
||||||
|
|
||||||
// Warranty/Item Details
|
// Warranty/Item Details
|
||||||
if document.documentType == "warranty" {
|
if document.documentType == "warranty" {
|
||||||
@@ -240,9 +240,9 @@ struct DocumentDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.systemBackground))
|
.background(Color.appBackgroundSecondary)
|
||||||
.cornerRadius(12)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.shadow(radius: 2)
|
.naturalShadow(.subtle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claim Information
|
// Claim Information
|
||||||
@@ -262,9 +262,9 @@ struct DocumentDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.systemBackground))
|
.background(Color.appBackgroundSecondary)
|
||||||
.cornerRadius(12)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.shadow(radius: 2)
|
.naturalShadow(.subtle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dates
|
// Dates
|
||||||
@@ -284,9 +284,9 @@ struct DocumentDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.systemBackground))
|
.background(Color.appBackgroundSecondary)
|
||||||
.cornerRadius(12)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.shadow(radius: 2)
|
.naturalShadow(.subtle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,16 +301,15 @@ struct DocumentDetailView: View {
|
|||||||
AuthenticatedImage(mediaURL: image.mediaUrl, contentMode: .fill)
|
AuthenticatedImage(mediaURL: image.mediaUrl, contentMode: .fill)
|
||||||
.frame(height: 100)
|
.frame(height: 100)
|
||||||
.clipped()
|
.clipped()
|
||||||
.cornerRadius(8)
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
selectedImageIndex = index
|
selectedImageIndex = index
|
||||||
showImageViewer = true
|
showImageViewer = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if index == 5 && document.images.count > 6 {
|
if index == 5 && document.images.count > 6 {
|
||||||
Rectangle()
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
.fill(Color.black.opacity(0.6))
|
.fill(Color.black.opacity(0.6))
|
||||||
.cornerRadius(8)
|
|
||||||
Text("+\(document.images.count - 6)")
|
Text("+\(document.images.count - 6)")
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
@@ -321,9 +320,9 @@ struct DocumentDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.systemBackground))
|
.background(Color.appBackgroundSecondary)
|
||||||
.cornerRadius(12)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.shadow(radius: 2)
|
.naturalShadow(.subtle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Associations
|
// Associations
|
||||||
@@ -341,9 +340,9 @@ struct DocumentDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.systemBackground))
|
.background(Color.appBackgroundSecondary)
|
||||||
.cornerRadius(12)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.shadow(radius: 2)
|
.naturalShadow(.subtle)
|
||||||
|
|
||||||
// Additional Information
|
// Additional Information
|
||||||
if document.tags != nil || document.notes != nil {
|
if document.tags != nil || document.notes != nil {
|
||||||
@@ -358,9 +357,9 @@ struct DocumentDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.systemBackground))
|
.background(Color.appBackgroundSecondary)
|
||||||
.cornerRadius(12)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.shadow(radius: 2)
|
.naturalShadow(.subtle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// File Information
|
// File Information
|
||||||
@@ -381,7 +380,7 @@ struct DocumentDetailView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
if isDownloading {
|
if isDownloading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.tint(.white)
|
.tint(Color.appTextOnPrimary)
|
||||||
.scaleEffect(0.8)
|
.scaleEffect(0.8)
|
||||||
Text("Downloading...")
|
Text("Downloading...")
|
||||||
} else {
|
} else {
|
||||||
@@ -392,8 +391,8 @@ struct DocumentDetailView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
.background(isDownloading ? Color.appPrimary.opacity(0.7) : Color.appPrimary)
|
.background(isDownloading ? Color.appPrimary.opacity(0.7) : Color.appPrimary)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
.cornerRadius(8)
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
}
|
}
|
||||||
.disabled(isDownloading)
|
.disabled(isDownloading)
|
||||||
|
|
||||||
@@ -404,9 +403,9 @@ struct DocumentDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.systemBackground))
|
.background(Color.appBackgroundSecondary)
|
||||||
.cornerRadius(12)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.shadow(radius: 2)
|
.naturalShadow(.subtle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
@@ -424,13 +423,13 @@ struct DocumentDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.systemBackground))
|
.background(Color.appBackgroundSecondary)
|
||||||
.cornerRadius(12)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.shadow(radius: 2)
|
.naturalShadow(.subtle)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(WarmGradientBackground())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -442,11 +441,10 @@ struct DocumentDetailView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(L10n.Documents.status)
|
Text(L10n.Documents.status)
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
Text(statusText)
|
Text(statusText)
|
||||||
.font(.title2)
|
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(statusColor)
|
.foregroundColor(statusColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,40 +453,40 @@ struct DocumentDetailView: View {
|
|||||||
if document.isActive && daysUntilExpiration >= 0 {
|
if document.isActive && daysUntilExpiration >= 0 {
|
||||||
VStack(alignment: .trailing, spacing: 4) {
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
Text(L10n.Documents.daysRemaining)
|
Text(L10n.Documents.daysRemaining)
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
Text("\(daysUntilExpiration)")
|
Text("\(daysUntilExpiration)")
|
||||||
.font(.title2)
|
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(statusColor)
|
.foregroundColor(statusColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(statusColor.opacity(0.1))
|
.background(statusColor.opacity(0.12))
|
||||||
.cornerRadius(12)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func sectionHeader(_ title: String) -> some View {
|
private func sectionHeader(_ title: String) -> some View {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.headline)
|
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||||
.fontWeight(.bold)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func detailRow(label: String, value: String) -> some View {
|
private func detailRow(label: String, value: String) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.body)
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(12)
|
.padding(14)
|
||||||
.background(Color(.secondarySystemGroupedBackground))
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
.cornerRadius(8)
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getStatusColor(isActive: Bool, daysUntilExpiration: Int32) -> Color {
|
private func getStatusColor(isActive: Bool, daysUntilExpiration: Int32) -> Color {
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ struct DocumentFormView: View {
|
|||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.appBackgroundPrimary)
|
.background(WarmGradientBackground())
|
||||||
.navigationTitle(isEditMode ? (isWarranty ? L10n.Documents.editWarranty : L10n.Documents.editDocument) : (isWarranty ? L10n.Documents.addWarranty : L10n.Documents.addDocument))
|
.navigationTitle(isEditMode ? (isWarranty ? L10n.Documents.editWarranty : L10n.Documents.editDocument) : (isWarranty ? L10n.Documents.addWarranty : L10n.Documents.addDocument))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
@@ -20,15 +20,12 @@ struct DocumentsWarrantiesView: View {
|
|||||||
|
|
||||||
let residenceId: Int32?
|
let residenceId: Int32?
|
||||||
|
|
||||||
// Client-side filtering for warranties tab
|
|
||||||
var warranties: [Document] {
|
var warranties: [Document] {
|
||||||
documentViewModel.documents.filter { doc in
|
documentViewModel.documents.filter { doc in
|
||||||
guard doc.documentType == "warranty" else { return false }
|
guard doc.documentType == "warranty" else { return false }
|
||||||
// Apply active filter if enabled
|
|
||||||
if showActiveOnly && doc.isActive != true {
|
if showActiveOnly && doc.isActive != true {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Apply category filter if selected
|
|
||||||
if let category = selectedCategory, doc.category != category {
|
if let category = selectedCategory, doc.category != category {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -36,11 +33,9 @@ struct DocumentsWarrantiesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client-side filtering for documents tab
|
|
||||||
var documents: [Document] {
|
var documents: [Document] {
|
||||||
documentViewModel.documents.filter { doc in
|
documentViewModel.documents.filter { doc in
|
||||||
guard doc.documentType != "warranty" else { return false }
|
guard doc.documentType != "warranty" else { return false }
|
||||||
// Apply document type filter if selected
|
|
||||||
if let docType = selectedDocType, doc.documentType != docType {
|
if let docType = selectedDocType, doc.documentType != docType {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -48,38 +43,31 @@ struct DocumentsWarrantiesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if upgrade screen should be shown (disables add button)
|
|
||||||
private var shouldShowUpgrade: Bool {
|
private var shouldShowUpgrade: Bool {
|
||||||
subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents")
|
subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents")
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.appBackgroundPrimary.ignoresSafeArea()
|
WarmGradientBackground()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Segmented Control for Tabs
|
// Segmented Control
|
||||||
Picker("", selection: $selectedTab) {
|
OrganicSegmentedControl(selection: $selectedTab)
|
||||||
Label(L10n.Documents.warranties, systemImage: "checkmark.shield")
|
.padding(.horizontal, 16)
|
||||||
.tag(DocumentWarrantyTab.warranties)
|
.padding(.top, 8)
|
||||||
Label(L10n.Documents.documents, systemImage: "doc.text")
|
|
||||||
.tag(DocumentWarrantyTab.documents)
|
|
||||||
}
|
|
||||||
.pickerStyle(SegmentedPickerStyle())
|
|
||||||
.padding(.horizontal, AppSpacing.md)
|
|
||||||
.padding(.top, AppSpacing.sm)
|
|
||||||
|
|
||||||
// Search Bar
|
// Search Bar
|
||||||
SearchBar(text: $searchText, placeholder: L10n.Documents.searchPlaceholder)
|
OrganicDocSearchBar(text: $searchText, placeholder: L10n.Documents.searchPlaceholder)
|
||||||
.padding(.horizontal, AppSpacing.md)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, AppSpacing.xs)
|
.padding(.top, 8)
|
||||||
|
|
||||||
// Active Filters
|
// Active Filters
|
||||||
if selectedCategory != nil || selectedDocType != nil || showActiveOnly {
|
if selectedCategory != nil || selectedDocType != nil || (selectedTab == .warranties && showActiveOnly) {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: AppSpacing.xs) {
|
HStack(spacing: 8) {
|
||||||
if selectedTab == .warranties && showActiveOnly {
|
if selectedTab == .warranties && showActiveOnly {
|
||||||
FilterChip(
|
OrganicDocFilterChip(
|
||||||
title: L10n.Documents.activeOnly,
|
title: L10n.Documents.activeOnly,
|
||||||
icon: "checkmark.circle.fill",
|
icon: "checkmark.circle.fill",
|
||||||
onRemove: { showActiveOnly = false }
|
onRemove: { showActiveOnly = false }
|
||||||
@@ -87,22 +75,22 @@ struct DocumentsWarrantiesView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let category = selectedCategory, selectedTab == .warranties {
|
if let category = selectedCategory, selectedTab == .warranties {
|
||||||
FilterChip(
|
OrganicDocFilterChip(
|
||||||
title: category,
|
title: category,
|
||||||
onRemove: { selectedCategory = nil }
|
onRemove: { selectedCategory = nil }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let docType = selectedDocType, selectedTab == .documents {
|
if let docType = selectedDocType, selectedTab == .documents {
|
||||||
FilterChip(
|
OrganicDocFilterChip(
|
||||||
title: docType,
|
title: docType,
|
||||||
onRemove: { selectedDocType = nil }
|
onRemove: { selectedDocType = nil }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.md)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
.padding(.vertical, AppSpacing.xs)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
@@ -119,22 +107,22 @@ struct DocumentsWarrantiesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(L10n.Documents.documentsAndWarranties)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack(spacing: AppSpacing.sm) {
|
HStack(spacing: 12) {
|
||||||
// Active Filter (for warranties) - client-side, no API call
|
// Active Filter (for warranties)
|
||||||
if selectedTab == .warranties {
|
if selectedTab == .warranties {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showActiveOnly.toggle()
|
showActiveOnly.toggle()
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: showActiveOnly ? "checkmark.circle.fill" : "checkmark.circle")
|
Image(systemName: showActiveOnly ? "checkmark.circle.fill" : "checkmark.circle")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
.foregroundColor(showActiveOnly ? Color.appPrimary : Color.appTextSecondary)
|
.foregroundColor(showActiveOnly ? Color.appPrimary : Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter Menu - client-side filtering, no API calls
|
// Filter Menu
|
||||||
Menu {
|
Menu {
|
||||||
if selectedTab == .warranties {
|
if selectedTab == .warranties {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@@ -171,35 +159,29 @@ struct DocumentsWarrantiesView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
.foregroundColor((selectedCategory != nil || selectedDocType != nil) ? Color.appPrimary : Color.appTextSecondary)
|
.foregroundColor((selectedCategory != nil || selectedDocType != nil) ? Color.appPrimary : Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Button (disabled when showing upgrade screen)
|
// Add Button
|
||||||
Button(action: {
|
Button(action: {
|
||||||
// Check LIVE document count before adding
|
|
||||||
let currentCount = documentViewModel.documents.count
|
let currentCount = documentViewModel.documents.count
|
||||||
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "documents") {
|
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "documents") {
|
||||||
// Track paywall shown
|
|
||||||
PostHogAnalytics.shared.capture(AnalyticsEvents.documentsPaywallShown, properties: ["current_count": currentCount])
|
PostHogAnalytics.shared.capture(AnalyticsEvents.documentsPaywallShown, properties: ["current_count": currentCount])
|
||||||
showingUpgradePrompt = true
|
showingUpgradePrompt = true
|
||||||
} else {
|
} else {
|
||||||
showAddSheet = true
|
showAddSheet = true
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "plus.circle.fill")
|
OrganicDocToolbarButton()
|
||||||
.font(.title2)
|
|
||||||
.foregroundColor(Color.appPrimary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Track screen view
|
|
||||||
PostHogAnalytics.shared.screen(AnalyticsEvents.documentsScreenShown)
|
PostHogAnalytics.shared.screen(AnalyticsEvents.documentsScreenShown)
|
||||||
// Load all documents once - filtering is client-side
|
|
||||||
loadAllDocuments()
|
loadAllDocuments()
|
||||||
}
|
}
|
||||||
// No need for onChange on selectedTab - filtering is client-side
|
|
||||||
.sheet(isPresented: $showAddSheet) {
|
.sheet(isPresented: $showAddSheet) {
|
||||||
AddDocumentView(
|
AddDocumentView(
|
||||||
residenceId: residenceId,
|
residenceId: residenceId,
|
||||||
@@ -214,23 +196,151 @@ struct DocumentsWarrantiesView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadAllDocuments(forceRefresh: Bool = false) {
|
private func loadAllDocuments(forceRefresh: Bool = false) {
|
||||||
// Load all documents without filters to use cache
|
|
||||||
// Filtering is done client-side in the computed properties
|
|
||||||
documentViewModel.loadDocuments(forceRefresh: forceRefresh)
|
documentViewModel.loadDocuments(forceRefresh: forceRefresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadWarranties() {
|
private func loadWarranties() {
|
||||||
// Just reload all - filtering happens client-side
|
|
||||||
loadAllDocuments()
|
loadAllDocuments()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadDocuments() {
|
private func loadDocuments() {
|
||||||
// Just reload all - filtering happens client-side
|
|
||||||
loadAllDocuments()
|
loadAllDocuments()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Organic Segmented Control
|
||||||
|
|
||||||
|
private struct OrganicSegmentedControl: View {
|
||||||
|
@Binding var selection: DocumentWarrantyTab
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
OrganicSegmentButton(
|
||||||
|
title: L10n.Documents.warranties,
|
||||||
|
icon: "checkmark.shield",
|
||||||
|
isSelected: selection == .warranties,
|
||||||
|
action: { selection = .warranties }
|
||||||
|
)
|
||||||
|
|
||||||
|
OrganicSegmentButton(
|
||||||
|
title: L10n.Documents.documents,
|
||||||
|
icon: "doc.text",
|
||||||
|
isSelected: selection == .documents,
|
||||||
|
action: { selection = .documents }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
.background(Color.appBackgroundSecondary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
.naturalShadow(.subtle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct OrganicSegmentButton: View {
|
||||||
|
let title: String
|
||||||
|
let icon: String
|
||||||
|
let isSelected: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(isSelected ? Color.appTextOnPrimary : Color.appTextSecondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(isSelected ? Color.appPrimary : Color.clear)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Organic Doc Search Bar
|
||||||
|
|
||||||
|
private struct OrganicDocSearchBar: View {
|
||||||
|
@Binding var text: String
|
||||||
|
var placeholder: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField(placeholder, text: $text)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
|
||||||
|
if !text.isEmpty {
|
||||||
|
Button(action: { text = "" }) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.appBackgroundSecondary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.naturalShadow(.subtle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Organic Doc Filter Chip
|
||||||
|
|
||||||
|
private struct OrganicDocFilterChip: View {
|
||||||
|
let title: String
|
||||||
|
var icon: String? = nil
|
||||||
|
let onRemove: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if let icon = icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
}
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
|
||||||
|
Button(action: onRemove) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.appPrimary.opacity(0.15))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Organic Doc Toolbar Button
|
||||||
|
|
||||||
|
private struct OrganicDocToolbarButton: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Supporting Types
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
extension DocumentCategory: CaseIterable {
|
extension DocumentCategory: CaseIterable {
|
||||||
public static var allCases: [DocumentCategory] {
|
public static var allCases: [DocumentCategory] {
|
||||||
return [.appliance, .hvac, .plumbing, .electrical, .roofing, .structural, .other]
|
return [.appliance, .hvac, .plumbing, .electrical, .roofing, .structural, .other]
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ struct MainTabView: View {
|
|||||||
}
|
}
|
||||||
.id(refreshID)
|
.id(refreshID)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Residences", image: "tab_view_house")
|
Label("Home", image: "tab_view_house")
|
||||||
}
|
}
|
||||||
.tag(0)
|
.tag(0)
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab)
|
||||||
@@ -24,7 +24,7 @@ struct MainTabView: View {
|
|||||||
}
|
}
|
||||||
.id(refreshID)
|
.id(refreshID)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Tasks", systemImage: "checkmark.circle.fill")
|
Label("Tasks", systemImage: "checklist")
|
||||||
}
|
}
|
||||||
.tag(1)
|
.tag(1)
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.tasksTab)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.tasksTab)
|
||||||
@@ -34,7 +34,7 @@ struct MainTabView: View {
|
|||||||
}
|
}
|
||||||
.id(refreshID)
|
.id(refreshID)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Contractors", systemImage: "wrench.and.screwdriver.fill")
|
Label("Pros", systemImage: "wrench.and.screwdriver.fill")
|
||||||
}
|
}
|
||||||
.tag(2)
|
.tag(2)
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab)
|
||||||
@@ -44,7 +44,7 @@ struct MainTabView: View {
|
|||||||
}
|
}
|
||||||
.id(refreshID)
|
.id(refreshID)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Documents", systemImage: "doc.text.fill")
|
Label("Docs", systemImage: "doc.text.fill")
|
||||||
}
|
}
|
||||||
.tag(3)
|
.tag(3)
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab)
|
||||||
@@ -53,23 +53,44 @@ struct MainTabView: View {
|
|||||||
.onChange(of: authManager.isAuthenticated) { _ in
|
.onChange(of: authManager.isAuthenticated) { _ in
|
||||||
selectedTab = 0
|
selectedTab = 0
|
||||||
}
|
}
|
||||||
// Check for pending navigation when view appears (app launched from notification)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
// Configure tab bar appearance
|
||||||
|
let appearance = UITabBarAppearance()
|
||||||
|
appearance.configureWithOpaqueBackground()
|
||||||
|
|
||||||
|
// Use theme-aware colors
|
||||||
|
appearance.backgroundColor = UIColor(Color.appBackgroundSecondary)
|
||||||
|
|
||||||
|
// Selected item
|
||||||
|
appearance.stackedLayoutAppearance.selected.iconColor = UIColor(Color.appPrimary)
|
||||||
|
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [
|
||||||
|
.foregroundColor: UIColor(Color.appPrimary),
|
||||||
|
.font: UIFont.systemFont(ofSize: 10, weight: .semibold)
|
||||||
|
]
|
||||||
|
|
||||||
|
// Normal item
|
||||||
|
appearance.stackedLayoutAppearance.normal.iconColor = UIColor(Color.appTextSecondary)
|
||||||
|
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [
|
||||||
|
.foregroundColor: UIColor(Color.appTextSecondary),
|
||||||
|
.font: UIFont.systemFont(ofSize: 10, weight: .medium)
|
||||||
|
]
|
||||||
|
|
||||||
|
UITabBar.appearance().standardAppearance = appearance
|
||||||
|
UITabBar.appearance().scrollEdgeAppearance = appearance
|
||||||
|
|
||||||
|
// Handle pending navigation from push notification
|
||||||
if pushManager.pendingNavigationTaskId != nil {
|
if pushManager.pendingNavigationTaskId != nil {
|
||||||
selectedTab = 1 // Switch to Tasks tab
|
selectedTab = 1
|
||||||
// Note: Don't clear here - AllTasksView will handle navigation and clear it
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Handle push notification deep links - switch to appropriate tab
|
|
||||||
// The actual task navigation is handled by AllTasksView
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in
|
||||||
selectedTab = 1 // Switch to Tasks tab
|
selectedTab = 1
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { _ in
|
||||||
selectedTab = 1 // Switch to Tasks tab
|
selectedTab = 1
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in
|
||||||
selectedTab = 0 // Switch to Residences tab
|
selectedTab = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
@StateObject private var appleSignInViewModel = AppleSignInViewModel()
|
@StateObject private var appleSignInViewModel = AppleSignInViewModel()
|
||||||
@State private var showingLoginSheet = false
|
@State private var showingLoginSheet = false
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
|
@State private var isAnimating = false
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
enum Field {
|
enum Field {
|
||||||
case username, email, password, confirmPassword
|
case username, email, password, confirmPassword
|
||||||
@@ -24,35 +26,87 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ZStack {
|
||||||
VStack(spacing: AppSpacing.xl) {
|
WarmGradientBackground()
|
||||||
// Header
|
|
||||||
VStack(spacing: AppSpacing.sm) {
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.fill(Color.appPrimary.opacity(0.1))
|
|
||||||
.frame(width: 80, height: 80)
|
|
||||||
|
|
||||||
Image(systemName: "person.badge.plus")
|
// Decorative blobs
|
||||||
.font(.system(size: 36))
|
GeometryReader { geo in
|
||||||
.foregroundStyle(Color.appPrimary.gradient)
|
OrganicBlobShape(variation: 1)
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(0.06),
|
||||||
|
Color.appPrimary.opacity(0.01),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.3
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.25)
|
||||||
|
.offset(x: geo.size.width * 0.55, y: geo.size.height * 0.05)
|
||||||
|
.blur(radius: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
|
// Header
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ZStack {
|
||||||
|
// Pulsing glow
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(0.15),
|
||||||
|
Color.appPrimary.opacity(0.05),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 60
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
|
.animation(
|
||||||
|
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
|
||||||
|
value: isAnimating
|
||||||
|
)
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
|
Image(systemName: "person.badge.plus")
|
||||||
|
.font(.system(size: 36, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.naturalShadow(.pronounced)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Save your home to your account")
|
Text("Save your home to your account")
|
||||||
.font(.title2)
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountTitle)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountTitle)
|
||||||
|
|
||||||
Text("Your data will be synced across devices")
|
Text("Your data will be synced across devices")
|
||||||
.font(.subheadline)
|
.font(.system(size: 15, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
.padding(.top, AppSpacing.lg)
|
.padding(.top, OrganicSpacing.comfortable)
|
||||||
|
|
||||||
// Sign in with Apple (Primary)
|
// Sign in with Apple (Primary)
|
||||||
VStack(spacing: AppSpacing.md) {
|
VStack(spacing: 14) {
|
||||||
SignInWithAppleButton(
|
SignInWithAppleButton(
|
||||||
onRequest: { request in
|
onRequest: { request in
|
||||||
request.requestedScopes = [.fullName, .email]
|
request.requestedScopes = [.fullName, .email]
|
||||||
@@ -60,7 +114,7 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
onCompletion: { _ in }
|
onCompletion: { _ in }
|
||||||
)
|
)
|
||||||
.frame(height: 56)
|
.frame(height: 56)
|
||||||
.cornerRadius(AppRadius.md)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.signInWithAppleButtonStyle(.black)
|
.signInWithAppleButtonStyle(.black)
|
||||||
.disabled(appleSignInViewModel.isLoading)
|
.disabled(appleSignInViewModel.isLoading)
|
||||||
.opacity(appleSignInViewModel.isLoading ? 0.6 : 1.0)
|
.opacity(appleSignInViewModel.isLoading ? 0.6 : 1.0)
|
||||||
@@ -73,122 +127,146 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if appleSignInViewModel.isLoading {
|
if appleSignInViewModel.isLoading {
|
||||||
HStack {
|
HStack(spacing: 10) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
.tint(Color.appPrimary)
|
||||||
Text("Signing in with Apple...")
|
Text("Signing in with Apple...")
|
||||||
.font(.subheadline)
|
.font(.system(size: 15, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let error = appleSignInViewModel.errorMessage {
|
if let error = appleSignInViewModel.errorMessage {
|
||||||
errorMessage(error)
|
OrganicErrorMessage(message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Divider
|
// Divider
|
||||||
HStack {
|
OrganicDividerWithText(text: "or")
|
||||||
Rectangle()
|
|
||||||
.fill(Color.appTextSecondary.opacity(0.3))
|
|
||||||
.frame(height: 1)
|
|
||||||
Text("or")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
.padding(.horizontal, AppSpacing.sm)
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color.appTextSecondary.opacity(0.3))
|
|
||||||
.frame(height: 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Account Form
|
// Create Account Form
|
||||||
VStack(spacing: AppSpacing.md) {
|
VStack(spacing: 14) {
|
||||||
if !isExpanded {
|
if !isExpanded {
|
||||||
// Collapsed state
|
// Collapsed state
|
||||||
Button(action: {
|
Button(action: {
|
||||||
withAnimation(.easeInOut(duration: 0.3)) {
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
|
||||||
isExpanded = true
|
isExpanded = true
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: AppSpacing.sm) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "envelope.fill")
|
ZStack {
|
||||||
.font(.title3)
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.15))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
Image(systemName: "envelope.fill")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
Text("Create Account with Email")
|
Text("Create Account with Email")
|
||||||
.font(.headline)
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.fontWeight(.medium)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 56)
|
.frame(height: 56)
|
||||||
.foregroundColor(Color.appPrimary)
|
.foregroundColor(Color.appPrimary)
|
||||||
.background(Color.appPrimary.opacity(0.1))
|
.background(Color.appPrimary.opacity(0.1))
|
||||||
.cornerRadius(AppRadius.md)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.stroke(Color.appPrimary.opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.emailSignUpExpandButton)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.emailSignUpExpandButton)
|
||||||
} else {
|
} else {
|
||||||
// Expanded form
|
// Expanded form
|
||||||
VStack(spacing: AppSpacing.md) {
|
VStack(spacing: 14) {
|
||||||
// Username
|
// Form card
|
||||||
formField(
|
VStack(spacing: 16) {
|
||||||
icon: "person.fill",
|
OrganicOnboardingTextField(
|
||||||
placeholder: "Username",
|
icon: "person.fill",
|
||||||
text: $viewModel.username,
|
placeholder: "Username",
|
||||||
field: .username,
|
text: $viewModel.username,
|
||||||
keyboardType: .default,
|
isFocused: focusedField == .username
|
||||||
contentType: .username
|
)
|
||||||
)
|
.focused($focusedField, equals: .username)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textContentType(.username)
|
||||||
|
|
||||||
// Email
|
OrganicOnboardingTextField(
|
||||||
formField(
|
icon: "envelope.fill",
|
||||||
icon: "envelope.fill",
|
placeholder: "Email",
|
||||||
placeholder: "Email",
|
text: $viewModel.email,
|
||||||
text: $viewModel.email,
|
isFocused: focusedField == .email
|
||||||
field: .email,
|
)
|
||||||
keyboardType: .emailAddress,
|
.focused($focusedField, equals: .email)
|
||||||
contentType: .emailAddress
|
.textInputAutocapitalization(.never)
|
||||||
)
|
.autocorrectionDisabled()
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.textContentType(.emailAddress)
|
||||||
|
|
||||||
// Password
|
OrganicOnboardingSecureField(
|
||||||
secureFormField(
|
icon: "lock.fill",
|
||||||
icon: "lock.fill",
|
placeholder: "Password",
|
||||||
placeholder: "Password",
|
text: $viewModel.password,
|
||||||
text: $viewModel.password,
|
isFocused: focusedField == .password
|
||||||
field: .password
|
)
|
||||||
)
|
.focused($focusedField, equals: .password)
|
||||||
|
|
||||||
// Confirm Password
|
OrganicOnboardingSecureField(
|
||||||
secureFormField(
|
icon: "lock.fill",
|
||||||
icon: "lock.fill",
|
placeholder: "Confirm Password",
|
||||||
placeholder: "Confirm Password",
|
text: $viewModel.confirmPassword,
|
||||||
text: $viewModel.confirmPassword,
|
isFocused: focusedField == .confirmPassword
|
||||||
field: .confirmPassword
|
)
|
||||||
|
.focused($focusedField, equals: .confirmPassword)
|
||||||
|
}
|
||||||
|
.padding(OrganicSpacing.cozy)
|
||||||
|
.background(
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
|
||||||
|
GeometryReader { geo in
|
||||||
|
OrganicBlobShape(variation: 2)
|
||||||
|
.fill(Color.appPrimary.opacity(colorScheme == .dark ? 0.04 : 0.02))
|
||||||
|
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.5)
|
||||||
|
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.5)
|
||||||
|
.blur(radius: 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
GrainTexture(opacity: 0.015)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
.naturalShadow(.medium)
|
||||||
|
|
||||||
if let error = viewModel.errorMessage {
|
if let error = viewModel.errorMessage {
|
||||||
errorMessage(error)
|
OrganicErrorMessage(message: error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register button
|
// Register button
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.register()
|
viewModel.register()
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack(spacing: 10) {
|
||||||
if viewModel.isLoading {
|
if viewModel.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
}
|
}
|
||||||
Text(viewModel.isLoading ? "Creating Account..." : "Create Account")
|
Text(viewModel.isLoading ? "Creating Account..." : "Create Account")
|
||||||
.font(.headline)
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 56)
|
.frame(height: 56)
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
.background(
|
.background(
|
||||||
isFormValid && !viewModel.isLoading
|
isFormValid && !viewModel.isLoading
|
||||||
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
: AnyShapeStyle(Color.appTextSecondary)
|
: AnyShapeStyle(Color.appTextSecondary.opacity(0.4))
|
||||||
)
|
)
|
||||||
.cornerRadius(AppRadius.md)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.shadow(color: isFormValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5)
|
.naturalShadow(isFormValid ? .medium : .subtle)
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountButton)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountButton)
|
||||||
.disabled(!isFormValid || viewModel.isLoading)
|
.disabled(!isFormValid || viewModel.isLoading)
|
||||||
@@ -197,25 +275,24 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already have an account
|
// Already have an account
|
||||||
HStack(spacing: AppSpacing.xs) {
|
HStack(spacing: 6) {
|
||||||
Text("Already have an account?")
|
Text("Already have an account?")
|
||||||
.font(.body)
|
.font(.system(size: 15, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
|
||||||
Button("Log in") {
|
Button("Log in") {
|
||||||
showingLoginSheet = true
|
showingLoginSheet = true
|
||||||
|
}
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
}
|
}
|
||||||
.font(.body)
|
.padding(.top, 8)
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(Color.appPrimary)
|
|
||||||
}
|
}
|
||||||
.padding(.top, AppSpacing.md)
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
|
.padding(.bottom, OrganicSpacing.airy)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
|
||||||
.padding(.bottom, AppSpacing.xxxl)
|
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.sheet(isPresented: $showingLoginSheet) {
|
.sheet(isPresented: $showingLoginSheet) {
|
||||||
LoginView(onLoginSuccess: {
|
LoginView(onLoginSuccess: {
|
||||||
showingLoginSheet = false
|
showingLoginSheet = false
|
||||||
@@ -229,6 +306,7 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
isAnimating = true
|
||||||
// Set up Apple Sign In callback
|
// Set up Apple Sign In callback
|
||||||
appleSignInViewModel.onSignInSuccess = { isVerified in
|
appleSignInViewModel.onSignInSuccess = { isVerified in
|
||||||
AuthenticationManager.shared.login(verified: isVerified)
|
AuthenticationManager.shared.login(verified: isVerified)
|
||||||
@@ -237,74 +315,139 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Form Fields
|
// MARK: - Organic Onboarding TextField
|
||||||
|
|
||||||
private func formField(
|
private struct OrganicOnboardingTextField: View {
|
||||||
icon: String,
|
let icon: String
|
||||||
placeholder: String,
|
let placeholder: String
|
||||||
text: Binding<String>,
|
@Binding var text: String
|
||||||
field: Field,
|
var isFocused: Bool = false
|
||||||
keyboardType: UIKeyboardType,
|
|
||||||
contentType: UITextContentType
|
|
||||||
) -> some View {
|
|
||||||
HStack(spacing: AppSpacing.sm) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
.frame(width: 20)
|
|
||||||
|
|
||||||
TextField(placeholder, text: text)
|
var body: some View {
|
||||||
.textInputAutocapitalization(.never)
|
HStack(spacing: 14) {
|
||||||
.autocorrectionDisabled()
|
ZStack {
|
||||||
.keyboardType(keyboardType)
|
Circle()
|
||||||
.textContentType(contentType)
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
.focused($focusedField, equals: field)
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField(placeholder, text: $text)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
}
|
}
|
||||||
.padding(AppSpacing.md)
|
.padding(14)
|
||||||
.background(Color.appBackgroundSecondary)
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
.cornerRadius(AppRadius.md)
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: AppRadius.md)
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
.stroke(focusedField == field ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
|
.stroke(isFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.2), lineWidth: 1.5)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func secureFormField(
|
// MARK: - Organic Onboarding Secure Field
|
||||||
icon: String,
|
|
||||||
placeholder: String,
|
|
||||||
text: Binding<String>,
|
|
||||||
field: Field
|
|
||||||
) -> some View {
|
|
||||||
HStack(spacing: AppSpacing.sm) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
.frame(width: 20)
|
|
||||||
|
|
||||||
SecureField(placeholder, text: text)
|
private struct OrganicOnboardingSecureField: View {
|
||||||
.textContentType(.password)
|
let icon: String
|
||||||
.focused($focusedField, equals: field)
|
let placeholder: String
|
||||||
|
@Binding var text: String
|
||||||
|
var isFocused: Bool = false
|
||||||
|
@State private var showPassword = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if showPassword {
|
||||||
|
TextField(placeholder, text: $text)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.textContentType(.password)
|
||||||
|
} else {
|
||||||
|
SecureField(placeholder, text: $text)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.textContentType(.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: { showPassword.toggle() }) {
|
||||||
|
Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(AppSpacing.md)
|
.padding(14)
|
||||||
.background(Color.appBackgroundSecondary)
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
.cornerRadius(AppRadius.md)
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: AppRadius.md)
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
.stroke(focusedField == field ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
|
.stroke(isFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.2), lineWidth: 1.5)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func errorMessage(_ message: String) -> some View {
|
// MARK: - Organic Error Message
|
||||||
HStack(spacing: AppSpacing.sm) {
|
|
||||||
|
private struct OrganicErrorMessage: View {
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
.foregroundColor(Color.appError)
|
.foregroundColor(Color.appError)
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.callout)
|
.font(.system(size: 14, weight: .medium))
|
||||||
.foregroundColor(Color.appError)
|
.foregroundColor(Color.appError)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(AppSpacing.md)
|
.padding(14)
|
||||||
.background(Color.appError.opacity(0.1))
|
.background(Color.appError.opacity(0.1))
|
||||||
.cornerRadius(AppRadius.md)
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Organic Divider with Text
|
||||||
|
|
||||||
|
private struct OrganicDividerWithText: View {
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.clear, Color.appTextSecondary.opacity(0.25)],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(height: 1)
|
||||||
|
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appTextSecondary.opacity(0.25), Color.clear],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(height: 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,32 +458,41 @@ struct OnboardingCreateAccountView: View {
|
|||||||
var onBack: () -> Void
|
var onBack: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
ZStack {
|
||||||
// Navigation bar
|
WarmGradientBackground()
|
||||||
HStack {
|
|
||||||
Button(action: onBack) {
|
VStack(spacing: 0) {
|
||||||
Image(systemName: "chevron.left")
|
// Navigation bar
|
||||||
.font(.title2)
|
HStack {
|
||||||
.foregroundColor(Color.appPrimary)
|
Button(action: onBack) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
OnboardingProgressIndicator(currentStep: 3, totalSteps: 5)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Invisible spacer for alignment
|
||||||
|
Circle()
|
||||||
|
.fill(Color.clear)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
Spacer()
|
OnboardingCreateAccountContent(onAccountCreated: onAccountCreated)
|
||||||
|
|
||||||
OnboardingProgressIndicator(currentStep: 3, totalSteps: 5)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Invisible spacer for alignment
|
|
||||||
Image(systemName: "chevron.left")
|
|
||||||
.font(.title2)
|
|
||||||
.opacity(0)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.lg)
|
|
||||||
.padding(.vertical, AppSpacing.md)
|
|
||||||
|
|
||||||
OnboardingCreateAccountContent(onAccountCreated: onAccountCreated)
|
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
@State private var isCreatingTasks = false
|
@State private var isCreatingTasks = false
|
||||||
@State private var showCustomTaskSheet = false
|
@State private var showCustomTaskSheet = false
|
||||||
@State private var expandedCategory: String? = nil
|
@State private var expandedCategory: String? = nil
|
||||||
|
@State private var isAnimating = false
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
/// Maximum tasks allowed for free tier (matches API TierLimits)
|
/// Maximum tasks allowed for free tier (matches API TierLimits)
|
||||||
private let maxTasksAllowed = 5
|
private let maxTasksAllowed = 5
|
||||||
@@ -99,196 +101,243 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
ZStack {
|
||||||
ScrollView {
|
WarmGradientBackground()
|
||||||
VStack(spacing: AppSpacing.xl) {
|
|
||||||
// Header with celebration
|
|
||||||
VStack(spacing: AppSpacing.md) {
|
|
||||||
ZStack {
|
|
||||||
// Celebration circles
|
|
||||||
Circle()
|
|
||||||
.fill(
|
|
||||||
RadialGradient(
|
|
||||||
colors: [Color.appPrimary.opacity(0.2), Color.clear],
|
|
||||||
center: .center,
|
|
||||||
startRadius: 30,
|
|
||||||
endRadius: 80
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.frame(width: 140, height: 140)
|
|
||||||
.offset(x: -15, y: -15)
|
|
||||||
|
|
||||||
Circle()
|
// Decorative blobs
|
||||||
.fill(
|
GeometryReader { geo in
|
||||||
RadialGradient(
|
OrganicBlobShape(variation: 1)
|
||||||
colors: [Color.appAccent.opacity(0.2), Color.clear],
|
.fill(
|
||||||
center: .center,
|
RadialGradient(
|
||||||
startRadius: 30,
|
colors: [
|
||||||
endRadius: 80
|
Color.appPrimary.opacity(0.06),
|
||||||
)
|
Color.appPrimary.opacity(0.01),
|
||||||
)
|
Color.clear
|
||||||
.frame(width: 140, height: 140)
|
],
|
||||||
.offset(x: 15, y: 15)
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.3
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.25)
|
||||||
|
.offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.1)
|
||||||
|
.blur(radius: 20)
|
||||||
|
|
||||||
// Party icon
|
OrganicBlobShape(variation: 2)
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appAccent.opacity(0.05),
|
||||||
|
Color.appAccent.opacity(0.01),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.25
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.2)
|
||||||
|
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.75)
|
||||||
|
.blur(radius: 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
|
// Header with celebration
|
||||||
|
VStack(spacing: 16) {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
// Celebration circles
|
||||||
Circle()
|
Circle()
|
||||||
.fill(
|
.fill(
|
||||||
LinearGradient(
|
RadialGradient(
|
||||||
colors: [Color.appPrimary, Color.appSecondary],
|
colors: [Color.appPrimary.opacity(0.15), Color.clear],
|
||||||
startPoint: .topLeading,
|
center: .center,
|
||||||
endPoint: .bottomTrailing
|
startRadius: 30,
|
||||||
|
endRadius: 80
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.frame(width: 80, height: 80)
|
.frame(width: 140, height: 140)
|
||||||
|
.offset(x: -15, y: -15)
|
||||||
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
|
.animation(
|
||||||
|
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
|
||||||
|
value: isAnimating
|
||||||
|
)
|
||||||
|
|
||||||
Image(systemName: "party.popper.fill")
|
Circle()
|
||||||
.font(.system(size: 36))
|
.fill(
|
||||||
.foregroundColor(.white)
|
RadialGradient(
|
||||||
|
colors: [Color.appAccent.opacity(0.15), Color.clear],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 30,
|
||||||
|
endRadius: 80
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 140, height: 140)
|
||||||
|
.offset(x: 15, y: 15)
|
||||||
|
.scaleEffect(isAnimating ? 0.95 : 1.05)
|
||||||
|
.animation(
|
||||||
|
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5),
|
||||||
|
value: isAnimating
|
||||||
|
)
|
||||||
|
|
||||||
|
// Party icon
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary, Color.appSecondary],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
|
Image(systemName: "party.popper.fill")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.naturalShadow(.pronounced)
|
||||||
}
|
}
|
||||||
.shadow(color: Color.appPrimary.opacity(0.4), radius: 15, y: 8)
|
|
||||||
|
Text("You're all set up!")
|
||||||
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
|
Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineSpacing(4)
|
||||||
}
|
}
|
||||||
|
.padding(.top, OrganicSpacing.comfortable)
|
||||||
|
|
||||||
Text("You're all set up!")
|
// Selection counter chip
|
||||||
.font(.title)
|
HStack(spacing: 8) {
|
||||||
.fontWeight(.bold)
|
Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill")
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
|
||||||
|
|
||||||
Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!")
|
Text("\(selectedCount)/\(maxTasksAllowed) tasks selected")
|
||||||
.font(.subheadline)
|
.font(.system(size: 14, weight: .semibold))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
|
||||||
.multilineTextAlignment(.center)
|
}
|
||||||
.lineSpacing(4)
|
.padding(.horizontal, 18)
|
||||||
}
|
.padding(.vertical, 10)
|
||||||
.padding(.top, AppSpacing.lg)
|
.background((isAtMaxSelection ? Color.appAccent : Color.appPrimary).opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.animation(.spring(response: 0.3), value: selectedCount)
|
||||||
|
|
||||||
// Selection counter chip
|
// Task categories
|
||||||
HStack(spacing: AppSpacing.sm) {
|
VStack(spacing: 12) {
|
||||||
Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill")
|
ForEach(taskCategories) { category in
|
||||||
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
|
OrganicTaskCategorySection(
|
||||||
|
category: category,
|
||||||
Text("\(selectedCount)/\(maxTasksAllowed) tasks selected")
|
selectedTasks: $selectedTasks,
|
||||||
.font(.subheadline)
|
isExpanded: expandedCategory == category.name,
|
||||||
.fontWeight(.medium)
|
isAtMaxSelection: isAtMaxSelection,
|
||||||
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
|
onToggleExpand: {
|
||||||
}
|
withAnimation(.spring(response: 0.3)) {
|
||||||
.padding(.horizontal, AppSpacing.lg)
|
if expandedCategory == category.name {
|
||||||
.padding(.vertical, AppSpacing.sm)
|
expandedCategory = nil
|
||||||
.background((isAtMaxSelection ? Color.appAccent : Color.appPrimary).opacity(0.1))
|
} else {
|
||||||
.cornerRadius(AppRadius.xl)
|
expandedCategory = category.name
|
||||||
.animation(.spring(response: 0.3), value: selectedCount)
|
}
|
||||||
|
|
||||||
// Task categories
|
|
||||||
VStack(spacing: AppSpacing.md) {
|
|
||||||
ForEach(taskCategories) { category in
|
|
||||||
TaskCategorySection(
|
|
||||||
category: category,
|
|
||||||
selectedTasks: $selectedTasks,
|
|
||||||
isExpanded: expandedCategory == category.name,
|
|
||||||
isAtMaxSelection: isAtMaxSelection,
|
|
||||||
onToggleExpand: {
|
|
||||||
withAnimation(.spring(response: 0.3)) {
|
|
||||||
if expandedCategory == category.name {
|
|
||||||
expandedCategory = nil
|
|
||||||
} else {
|
|
||||||
expandedCategory = category.name
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
|
|
||||||
|
// Quick add all popular
|
||||||
|
Button(action: selectPopularTasks) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
|
||||||
|
Text("Add Most Popular")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary, Color.appAccent],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.stroke(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
),
|
||||||
|
lineWidth: 1.5
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.lg)
|
.padding(.bottom, 140) // Space for button
|
||||||
|
}
|
||||||
|
|
||||||
// Quick add all popular
|
// Bottom action area
|
||||||
Button(action: selectPopularTasks) {
|
VStack(spacing: 14) {
|
||||||
HStack(spacing: AppSpacing.sm) {
|
Button(action: addSelectedTasks) {
|
||||||
Image(systemName: "sparkles")
|
HStack(spacing: 10) {
|
||||||
.font(.headline)
|
if isCreatingTasks {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
} else {
|
||||||
|
Text(selectedCount > 0 ? "Add \(selectedCount) Task\(selectedCount == 1 ? "" : "s") & Continue" : "Skip for Now")
|
||||||
|
.font(.system(size: 17, weight: .bold))
|
||||||
|
|
||||||
Text("Add Most Popular")
|
Image(systemName: "arrow.right")
|
||||||
.font(.headline)
|
.font(.system(size: 16, weight: .bold))
|
||||||
.fontWeight(.medium)
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.appPrimary, Color.appAccent],
|
|
||||||
startPoint: .leading,
|
|
||||||
endPoint: .trailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 56)
|
.frame(height: 56)
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
.background(
|
.background(
|
||||||
LinearGradient(
|
selectedCount > 0
|
||||||
colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)],
|
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing))
|
||||||
startPoint: .leading,
|
: AnyShapeStyle(Color.appTextSecondary.opacity(0.5))
|
||||||
endPoint: .trailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.cornerRadius(AppRadius.lg)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: AppRadius.lg)
|
|
||||||
.stroke(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)],
|
|
||||||
startPoint: .leading,
|
|
||||||
endPoint: .trailing
|
|
||||||
),
|
|
||||||
lineWidth: 1.5
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.naturalShadow(selectedCount > 0 ? .medium : .subtle)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.lg)
|
.disabled(isCreatingTasks)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: selectedCount)
|
||||||
}
|
}
|
||||||
.padding(.bottom, 140) // Space for button
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
}
|
.padding(.bottom, OrganicSpacing.airy)
|
||||||
|
.background(
|
||||||
// Bottom action area
|
LinearGradient(
|
||||||
VStack(spacing: AppSpacing.md) {
|
colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary],
|
||||||
Button(action: addSelectedTasks) {
|
startPoint: .top,
|
||||||
HStack(spacing: AppSpacing.sm) {
|
endPoint: .center
|
||||||
if isCreatingTasks {
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
||||||
} else {
|
|
||||||
Text(selectedCount > 0 ? "Add \(selectedCount) Task\(selectedCount == 1 ? "" : "s") & Continue" : "Skip for Now")
|
|
||||||
.font(.headline)
|
|
||||||
.fontWeight(.bold)
|
|
||||||
|
|
||||||
Image(systemName: "arrow.right")
|
|
||||||
.font(.headline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: 56)
|
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
|
||||||
.background(
|
|
||||||
selectedCount > 0
|
|
||||||
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing))
|
|
||||||
: AnyShapeStyle(Color.appTextSecondary.opacity(0.5))
|
|
||||||
)
|
)
|
||||||
.cornerRadius(AppRadius.lg)
|
.frame(height: 60)
|
||||||
.shadow(color: selectedCount > 0 ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8)
|
.offset(y: -60)
|
||||||
}
|
, alignment: .top
|
||||||
.disabled(isCreatingTasks)
|
|
||||||
.animation(.easeInOut(duration: 0.2), value: selectedCount)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
|
||||||
.padding(.bottom, AppSpacing.xxxl)
|
|
||||||
.background(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .center
|
|
||||||
)
|
)
|
||||||
.frame(height: 60)
|
}
|
||||||
.offset(y: -60)
|
|
||||||
, alignment: .top
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
isAnimating = true
|
||||||
// Expand first category by default
|
// Expand first category by default
|
||||||
expandedCategory = taskCategories.first?.name
|
expandedCategory = taskCategories.first?.name
|
||||||
}
|
}
|
||||||
@@ -393,15 +442,17 @@ struct OnboardingTaskCategory: Identifiable {
|
|||||||
let tasks: [OnboardingTaskTemplate]
|
let tasks: [OnboardingTaskTemplate]
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Task Category Section
|
// MARK: - Organic Task Category Section
|
||||||
|
|
||||||
struct TaskCategorySection: View {
|
private struct OrganicTaskCategorySection: View {
|
||||||
let category: OnboardingTaskCategory
|
let category: OnboardingTaskCategory
|
||||||
@Binding var selectedTasks: Set<UUID>
|
@Binding var selectedTasks: Set<UUID>
|
||||||
let isExpanded: Bool
|
let isExpanded: Bool
|
||||||
let isAtMaxSelection: Bool
|
let isAtMaxSelection: Bool
|
||||||
var onToggleExpand: () -> Void
|
var onToggleExpand: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
private var selectedInCategory: Int {
|
private var selectedInCategory: Int {
|
||||||
category.tasks.filter { selectedTasks.contains($0.id) }.count
|
category.tasks.filter { selectedTasks.contains($0.id) }.count
|
||||||
}
|
}
|
||||||
@@ -410,7 +461,7 @@ struct TaskCategorySection: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Category header
|
// Category header
|
||||||
Button(action: onToggleExpand) {
|
Button(action: onToggleExpand) {
|
||||||
HStack(spacing: AppSpacing.md) {
|
HStack(spacing: 14) {
|
||||||
// Category icon
|
// Category icon
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -424,14 +475,14 @@ struct TaskCategorySection: View {
|
|||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
Image(systemName: category.icon)
|
Image(systemName: category.icon)
|
||||||
.font(.title3)
|
.font(.system(size: 18, weight: .medium))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
|
.naturalShadow(.subtle)
|
||||||
|
|
||||||
// Category name
|
// Category name
|
||||||
Text(category.name)
|
Text(category.name)
|
||||||
.font(.headline)
|
.font(.system(size: 16, weight: .semibold))
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -439,8 +490,7 @@ struct TaskCategorySection: View {
|
|||||||
// Selection badge
|
// Selection badge
|
||||||
if selectedInCategory > 0 {
|
if selectedInCategory > 0 {
|
||||||
Text("\(selectedInCategory)")
|
Text("\(selectedInCategory)")
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .bold))
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.frame(width: 24, height: 24)
|
.frame(width: 24, height: 24)
|
||||||
.background(category.color)
|
.background(category.color)
|
||||||
@@ -449,13 +499,26 @@ struct TaskCategorySection: View {
|
|||||||
|
|
||||||
// Chevron
|
// Chevron
|
||||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .semibold))
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
.padding(AppSpacing.md)
|
.padding(14)
|
||||||
.background(Color.appBackgroundSecondary)
|
.background(
|
||||||
.cornerRadius(isExpanded ? AppRadius.lg : AppRadius.lg, corners: isExpanded ? [.topLeft, .topRight] : .allCorners)
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
GrainTexture(opacity: 0.01)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: isExpanded ? 18 : 18, style: .continuous))
|
||||||
|
.clipShape(
|
||||||
|
UnevenRoundedRectangle(
|
||||||
|
topLeadingRadius: 18,
|
||||||
|
bottomLeadingRadius: isExpanded ? 0 : 18,
|
||||||
|
bottomTrailingRadius: isExpanded ? 0 : 18,
|
||||||
|
topTrailingRadius: 18,
|
||||||
|
style: .continuous
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
@@ -464,7 +527,7 @@ struct TaskCategorySection: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(category.tasks) { task in
|
ForEach(category.tasks) { task in
|
||||||
let taskIsSelected = selectedTasks.contains(task.id)
|
let taskIsSelected = selectedTasks.contains(task.id)
|
||||||
OnboardingTaskTemplateRow(
|
OrganicTaskTemplateRow(
|
||||||
template: task,
|
template: task,
|
||||||
isSelected: taskIsSelected,
|
isSelected: taskIsSelected,
|
||||||
isDisabled: isAtMaxSelection && !taskIsSelected,
|
isDisabled: isAtMaxSelection && !taskIsSelected,
|
||||||
@@ -486,16 +549,24 @@ struct TaskCategorySection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundSecondary.opacity(0.5))
|
.background(Color.appBackgroundSecondary.opacity(0.5))
|
||||||
.cornerRadius(AppRadius.lg, corners: [.bottomLeft, .bottomRight])
|
.clipShape(
|
||||||
|
UnevenRoundedRectangle(
|
||||||
|
topLeadingRadius: 0,
|
||||||
|
bottomLeadingRadius: 18,
|
||||||
|
bottomTrailingRadius: 18,
|
||||||
|
topTrailingRadius: 0,
|
||||||
|
style: .continuous
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.shadow(color: Color.black.opacity(0.05), radius: 8, y: 4)
|
.naturalShadow(.subtle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Task Template Row
|
// MARK: - Organic Task Template Row
|
||||||
|
|
||||||
struct OnboardingTaskTemplateRow: View {
|
private struct OrganicTaskTemplateRow: View {
|
||||||
let template: OnboardingTaskTemplate
|
let template: OnboardingTaskTemplate
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
let isDisabled: Bool
|
let isDisabled: Bool
|
||||||
@@ -503,7 +574,7 @@ struct OnboardingTaskTemplateRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onTap) {
|
Button(action: onTap) {
|
||||||
HStack(spacing: AppSpacing.md) {
|
HStack(spacing: 14) {
|
||||||
// Checkbox
|
// Checkbox
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -516,8 +587,7 @@ struct OnboardingTaskTemplateRow: View {
|
|||||||
.frame(width: 28, height: 28)
|
.frame(width: 28, height: 28)
|
||||||
|
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .bold))
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,12 +595,11 @@ struct OnboardingTaskTemplateRow: View {
|
|||||||
// Task info
|
// Task info
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(template.title)
|
Text(template.title)
|
||||||
.font(.subheadline)
|
.font(.system(size: 15, weight: .medium))
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(isDisabled ? Color.appTextSecondary.opacity(0.5) : Color.appTextPrimary)
|
.foregroundColor(isDisabled ? Color.appTextSecondary.opacity(0.5) : Color.appTextPrimary)
|
||||||
|
|
||||||
Text(template.frequency.capitalized)
|
Text(template.frequency.capitalized)
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary.opacity(isDisabled ? 0.5 : 1))
|
.foregroundColor(Color.appTextSecondary.opacity(isDisabled ? 0.5 : 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,11 +607,11 @@ struct OnboardingTaskTemplateRow: View {
|
|||||||
|
|
||||||
// Task icon
|
// Task icon
|
||||||
Image(systemName: template.icon)
|
Image(systemName: template.icon)
|
||||||
.font(.title3)
|
.font(.system(size: 18, weight: .medium))
|
||||||
.foregroundColor(template.color.opacity(isDisabled ? 0.3 : 0.6))
|
.foregroundColor(template.color.opacity(isDisabled ? 0.3 : 0.6))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.md)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, AppSpacing.sm)
|
.padding(.vertical, 12)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -569,27 +638,29 @@ struct OnboardingFirstTaskView: View {
|
|||||||
var onSkip: () -> Void
|
var onSkip: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
ZStack {
|
||||||
// Navigation bar
|
WarmGradientBackground()
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: onSkip) {
|
VStack(spacing: 0) {
|
||||||
Text("Skip")
|
// Navigation bar
|
||||||
.font(.subheadline)
|
HStack {
|
||||||
.fontWeight(.medium)
|
Spacer()
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
|
Button(action: onSkip) {
|
||||||
|
Text("Skip")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
.padding(.horizontal, 20)
|
||||||
.padding(.horizontal, AppSpacing.lg)
|
.padding(.vertical, 12)
|
||||||
.padding(.vertical, AppSpacing.md)
|
|
||||||
|
|
||||||
OnboardingFirstTaskContent(
|
OnboardingFirstTaskContent(
|
||||||
residenceName: residenceName,
|
residenceName: residenceName,
|
||||||
onTaskAdded: onTaskAdded
|
onTaskAdded: onTaskAdded
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,123 +9,215 @@ struct OnboardingJoinResidenceContent: View {
|
|||||||
@State private var shareCode: String = ""
|
@State private var shareCode: String = ""
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
@State private var isAnimating = false
|
||||||
@FocusState private var isCodeFieldFocused: Bool
|
@FocusState private var isCodeFieldFocused: Bool
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
private var isCodeValid: Bool {
|
private var isCodeValid: Bool {
|
||||||
shareCode.count == 6
|
shareCode.count == 6
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
ZStack {
|
||||||
Spacer()
|
WarmGradientBackground()
|
||||||
|
|
||||||
// Content
|
// Decorative blobs
|
||||||
VStack(spacing: AppSpacing.xl) {
|
GeometryReader { geo in
|
||||||
// Icon
|
OrganicBlobShape(variation: 1)
|
||||||
ZStack {
|
.fill(
|
||||||
Circle()
|
RadialGradient(
|
||||||
.fill(Color.appPrimary.opacity(0.1))
|
colors: [
|
||||||
.frame(width: 100, height: 100)
|
Color.appPrimary.opacity(0.07),
|
||||||
|
Color.appPrimary.opacity(0.02),
|
||||||
Image(systemName: "person.2.badge.key.fill")
|
Color.clear
|
||||||
.font(.system(size: 44))
|
],
|
||||||
.foregroundStyle(Color.appPrimary.gradient)
|
center: .center,
|
||||||
}
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.35
|
||||||
// Title
|
)
|
||||||
VStack(spacing: AppSpacing.sm) {
|
|
||||||
Text("Join a Residence")
|
|
||||||
.font(.title2)
|
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
|
||||||
|
|
||||||
Text("Enter the 6-character code shared with you to join an existing home.")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code input
|
|
||||||
VStack(alignment: .leading, spacing: AppSpacing.xs) {
|
|
||||||
HStack(spacing: AppSpacing.sm) {
|
|
||||||
Image(systemName: "key.fill")
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
.frame(width: 20)
|
|
||||||
|
|
||||||
TextField("Enter share code", text: $shareCode)
|
|
||||||
.textInputAutocapitalization(.characters)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.focused($isCodeFieldFocused)
|
|
||||||
.onChange(of: shareCode) { _, newValue in
|
|
||||||
// Limit to 6 characters
|
|
||||||
if newValue.count > 6 {
|
|
||||||
shareCode = String(newValue.prefix(6))
|
|
||||||
}
|
|
||||||
// Clear error when typing
|
|
||||||
errorMessage = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(AppSpacing.md)
|
|
||||||
.background(Color.appBackgroundSecondary)
|
|
||||||
.cornerRadius(AppRadius.md)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: AppRadius.md)
|
|
||||||
.stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
|
|
||||||
)
|
)
|
||||||
}
|
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.35)
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
.offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.1)
|
||||||
|
.blur(radius: 25)
|
||||||
|
|
||||||
// Error message
|
OrganicBlobShape(variation: 2)
|
||||||
if let error = errorMessage {
|
.fill(
|
||||||
HStack(spacing: AppSpacing.sm) {
|
RadialGradient(
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
colors: [
|
||||||
.foregroundColor(Color.appError)
|
Color.appAccent.opacity(0.05),
|
||||||
Text(error)
|
Color.appAccent.opacity(0.01),
|
||||||
.font(.callout)
|
Color.clear
|
||||||
.foregroundColor(Color.appError)
|
],
|
||||||
Spacer()
|
center: .center,
|
||||||
}
|
startRadius: 0,
|
||||||
.padding(AppSpacing.md)
|
endRadius: geo.size.width * 0.25
|
||||||
.background(Color.appError.opacity(0.1))
|
)
|
||||||
.cornerRadius(AppRadius.md)
|
)
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.25)
|
||||||
}
|
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.6)
|
||||||
|
.blur(radius: 20)
|
||||||
// Loading indicator
|
|
||||||
if isLoading {
|
|
||||||
HStack {
|
|
||||||
ProgressView()
|
|
||||||
Text("Joining residence...")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
VStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
// Join button
|
// Content
|
||||||
Button(action: joinResidence) {
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
Text("Join Residence")
|
// Icon with pulsing glow
|
||||||
.font(.headline)
|
ZStack {
|
||||||
.fontWeight(.semibold)
|
Circle()
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(0.15),
|
||||||
|
Color.appPrimary.opacity(0.05),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 70
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 140, height: 140)
|
||||||
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
|
.animation(
|
||||||
|
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
|
||||||
|
value: isAnimating
|
||||||
|
)
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 90, height: 90)
|
||||||
|
|
||||||
|
Image(systemName: "person.2.badge.key.fill")
|
||||||
|
.font(.system(size: 40, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.naturalShadow(.pronounced)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Text("Join a Residence")
|
||||||
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
|
Text("Enter the 6-character code shared with you to join an existing home.")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineSpacing(4)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code input card
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
|
Image(systemName: "key.fill")
|
||||||
|
.font(.system(size: 17, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Enter share code", text: $shareCode)
|
||||||
|
.font(.system(size: 20, weight: .semibold, design: .monospaced))
|
||||||
|
.textInputAutocapitalization(.characters)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($isCodeFieldFocused)
|
||||||
|
.onChange(of: shareCode) { _, newValue in
|
||||||
|
// Limit to 6 characters
|
||||||
|
if newValue.count > 6 {
|
||||||
|
shareCode = String(newValue.prefix(6))
|
||||||
|
}
|
||||||
|
// Clear error when typing
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(18)
|
||||||
|
.background(
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
GrainTexture(opacity: 0.01)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.2), lineWidth: 2)
|
||||||
|
)
|
||||||
|
.naturalShadow(.medium)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if let error = errorMessage {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.appError.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading indicator
|
||||||
|
if isLoading {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
.tint(Color.appPrimary)
|
||||||
|
Text("Joining residence...")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Join button
|
||||||
|
Button(action: joinResidence) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
}
|
||||||
|
Text(isLoading ? "Joining..." : "Join Residence")
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 56)
|
.frame(height: 56)
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
.background(
|
.background(
|
||||||
isCodeValid && !isLoading
|
isCodeValid && !isLoading
|
||||||
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
: AnyShapeStyle(Color.appTextSecondary)
|
: AnyShapeStyle(Color.appTextSecondary.opacity(0.4))
|
||||||
)
|
)
|
||||||
.cornerRadius(AppRadius.md)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.shadow(color: isCodeValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5)
|
.naturalShadow(isCodeValid ? .medium : .subtle)
|
||||||
|
}
|
||||||
|
.disabled(!isCodeValid || isLoading)
|
||||||
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
|
.padding(.bottom, OrganicSpacing.airy)
|
||||||
}
|
}
|
||||||
.disabled(!isCodeValid || isLoading)
|
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
|
||||||
.padding(.bottom, AppSpacing.xxxl)
|
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
isAnimating = true
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
isCodeFieldFocused = true
|
isCodeFieldFocused = true
|
||||||
}
|
}
|
||||||
@@ -161,24 +253,26 @@ struct OnboardingJoinResidenceView: View {
|
|||||||
var onSkip: () -> Void
|
var onSkip: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
ZStack {
|
||||||
// Navigation bar
|
WarmGradientBackground()
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: onSkip) {
|
VStack(spacing: 0) {
|
||||||
Text("Skip")
|
// Navigation bar
|
||||||
.font(.subheadline)
|
HStack {
|
||||||
.fontWeight(.medium)
|
Spacer()
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
|
Button(action: onSkip) {
|
||||||
|
Text("Skip")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
.padding(.horizontal, 20)
|
||||||
.padding(.horizontal, AppSpacing.lg)
|
.padding(.vertical, 12)
|
||||||
.padding(.vertical, AppSpacing.md)
|
|
||||||
|
|
||||||
OnboardingJoinResidenceContent(onJoined: onJoined)
|
OnboardingJoinResidenceContent(onJoined: onJoined)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ struct OnboardingNameResidenceContent: View {
|
|||||||
|
|
||||||
@FocusState private var isTextFieldFocused: Bool
|
@FocusState private var isTextFieldFocused: Bool
|
||||||
@State private var showSuggestions = false
|
@State private var showSuggestions = false
|
||||||
|
@State private var isAnimating = false
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
private var isValid: Bool {
|
private var isValid: Bool {
|
||||||
!residenceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
!residenceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
@@ -21,177 +23,240 @@ struct OnboardingNameResidenceContent: View {
|
|||||||
]
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
ZStack {
|
||||||
Spacer()
|
WarmGradientBackground()
|
||||||
|
|
||||||
// Content
|
// Decorative blobs
|
||||||
VStack(spacing: AppSpacing.xl) {
|
GeometryReader { geo in
|
||||||
// Animated house icon
|
OrganicBlobShape(variation: 2)
|
||||||
ZStack {
|
.fill(
|
||||||
// Colorful background circles
|
RadialGradient(
|
||||||
Circle()
|
colors: [
|
||||||
.fill(
|
Color.appAccent.opacity(0.08),
|
||||||
RadialGradient(
|
Color.appAccent.opacity(0.02),
|
||||||
colors: [Color.appAccent.opacity(0.2), Color.clear],
|
Color.clear
|
||||||
center: .center,
|
],
|
||||||
startRadius: 30,
|
center: .center,
|
||||||
endRadius: 80
|
startRadius: 0,
|
||||||
)
|
endRadius: geo.size.width * 0.35
|
||||||
)
|
)
|
||||||
.frame(width: 160, height: 160)
|
)
|
||||||
.offset(x: -20, y: -20)
|
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.35)
|
||||||
|
.offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.05)
|
||||||
|
.blur(radius: 25)
|
||||||
|
|
||||||
Circle()
|
OrganicBlobShape(variation: 0)
|
||||||
.fill(
|
.fill(
|
||||||
RadialGradient(
|
RadialGradient(
|
||||||
colors: [Color.appPrimary.opacity(0.2), Color.clear],
|
colors: [
|
||||||
center: .center,
|
Color.appPrimary.opacity(0.06),
|
||||||
startRadius: 30,
|
Color.appPrimary.opacity(0.01),
|
||||||
endRadius: 80
|
Color.clear
|
||||||
)
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.3
|
||||||
)
|
)
|
||||||
.frame(width: 160, height: 160)
|
)
|
||||||
.offset(x: 20, y: 20)
|
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.3)
|
||||||
|
.offset(x: geo.size.width * 0.55, y: geo.size.height * 0.6)
|
||||||
|
.blur(radius: 20)
|
||||||
|
}
|
||||||
|
|
||||||
// Main icon
|
VStack(spacing: 0) {
|
||||||
Image("icon")
|
Spacer()
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
.frame(width: 100, height: 100)
|
|
||||||
.shadow(color: Color.appPrimary.opacity(0.3), radius: 15, y: 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Title with playful wording
|
// Content
|
||||||
VStack(spacing: AppSpacing.md) {
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
Text("Let's give your place a name!")
|
// Animated house icon
|
||||||
.font(.title)
|
ZStack {
|
||||||
.fontWeight(.bold)
|
// Pulsing glow circles
|
||||||
.foregroundColor(Color.appTextPrimary)
|
Circle()
|
||||||
.multilineTextAlignment(.center)
|
.fill(
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceTitle)
|
RadialGradient(
|
||||||
|
colors: [Color.appAccent.opacity(0.15), Color.clear],
|
||||||
Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.")
|
center: .center,
|
||||||
.font(.subheadline)
|
startRadius: 30,
|
||||||
.foregroundColor(Color.appTextSecondary)
|
endRadius: 80
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.lineSpacing(4)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text field with gradient border when focused
|
|
||||||
VStack(alignment: .leading, spacing: AppSpacing.sm) {
|
|
||||||
HStack(spacing: AppSpacing.sm) {
|
|
||||||
Image(systemName: "house.fill")
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundStyle(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.appPrimary, Color.appAccent],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.frame(width: 24)
|
.frame(width: 160, height: 160)
|
||||||
|
.offset(x: -20, y: -20)
|
||||||
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
|
.animation(
|
||||||
|
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
|
||||||
|
value: isAnimating
|
||||||
|
)
|
||||||
|
|
||||||
TextField("The Smith Residence", text: $residenceName)
|
Circle()
|
||||||
.font(.body)
|
.fill(
|
||||||
.fontWeight(.medium)
|
RadialGradient(
|
||||||
.textInputAutocapitalization(.words)
|
colors: [Color.appPrimary.opacity(0.15), Color.clear],
|
||||||
.focused($isTextFieldFocused)
|
center: .center,
|
||||||
.submitLabel(.continue)
|
startRadius: 30,
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.residenceNameField)
|
endRadius: 80
|
||||||
.onSubmit {
|
)
|
||||||
if isValid {
|
)
|
||||||
onContinue()
|
.frame(width: 160, height: 160)
|
||||||
|
.offset(x: 20, y: 20)
|
||||||
|
.scaleEffect(isAnimating ? 0.95 : 1.05)
|
||||||
|
.animation(
|
||||||
|
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5),
|
||||||
|
value: isAnimating
|
||||||
|
)
|
||||||
|
|
||||||
|
// Main icon
|
||||||
|
Image("icon")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
.naturalShadow(.pronounced)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title with playful wording
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("Let's give your place a name!")
|
||||||
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceTitle)
|
||||||
|
|
||||||
|
Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineSpacing(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text field with organic styling
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary.opacity(0.15), Color.appAccent.opacity(0.1)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
|
Image(systemName: "house.fill")
|
||||||
|
.font(.system(size: 18, weight: .medium))
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary, Color.appAccent],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("The Smith Residence", text: $residenceName)
|
||||||
|
.font(.system(size: 17, weight: .medium))
|
||||||
|
.textInputAutocapitalization(.words)
|
||||||
|
.focused($isTextFieldFocused)
|
||||||
|
.submitLabel(.continue)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.residenceNameField)
|
||||||
|
.onSubmit {
|
||||||
|
if isValid {
|
||||||
|
onContinue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !residenceName.isEmpty {
|
||||||
|
Button(action: { residenceName = "" }) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(Color.appTextSecondary.opacity(0.5))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !residenceName.isEmpty {
|
|
||||||
Button(action: { residenceName = "" }) {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.foregroundColor(Color.appTextSecondary.opacity(0.5))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
.padding(18)
|
||||||
.padding(AppSpacing.lg)
|
.background(
|
||||||
.background(Color.appBackgroundSecondary)
|
ZStack {
|
||||||
.cornerRadius(AppRadius.lg)
|
Color.appBackgroundSecondary
|
||||||
.overlay(
|
GrainTexture(opacity: 0.01)
|
||||||
RoundedRectangle(cornerRadius: AppRadius.lg)
|
}
|
||||||
.stroke(
|
)
|
||||||
isTextFieldFocused
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||||
? LinearGradient(colors: [Color.appPrimary, Color.appAccent], startPoint: .leading, endPoint: .trailing)
|
.overlay(
|
||||||
: LinearGradient(colors: [Color.appTextSecondary.opacity(0.3), Color.appTextSecondary.opacity(0.3)], startPoint: .leading, endPoint: .trailing),
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
lineWidth: 2
|
.stroke(
|
||||||
)
|
isTextFieldFocused
|
||||||
)
|
? LinearGradient(colors: [Color.appPrimary, Color.appAccent], startPoint: .leading, endPoint: .trailing)
|
||||||
.shadow(color: isTextFieldFocused ? Color.appPrimary.opacity(0.15) : .clear, radius: 12, y: 4)
|
: LinearGradient(colors: [Color.appTextSecondary.opacity(0.2), Color.appTextSecondary.opacity(0.2)], startPoint: .leading, endPoint: .trailing),
|
||||||
.animation(.easeInOut(duration: 0.2), value: isTextFieldFocused)
|
lineWidth: 2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.naturalShadow(isTextFieldFocused ? .medium : .subtle)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: isTextFieldFocused)
|
||||||
|
|
||||||
// Name suggestions
|
// Name suggestions
|
||||||
if residenceName.isEmpty {
|
if residenceName.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: AppSpacing.xs) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Need inspiration?")
|
Text("Need inspiration?")
|
||||||
.font(.caption)
|
.font(.system(size: 13, weight: .semibold))
|
||||||
.fontWeight(.medium)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.padding(.top, 4)
|
||||||
.padding(.top, AppSpacing.xs)
|
|
||||||
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: AppSpacing.sm) {
|
HStack(spacing: 10) {
|
||||||
ForEach(nameSuggestions, id: \.self) { suggestion in
|
ForEach(nameSuggestions, id: \.self) { suggestion in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
withAnimation(.spring(response: 0.3)) {
|
withAnimation(.spring(response: 0.3)) {
|
||||||
residenceName = suggestion
|
residenceName = suggestion
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text(suggestion)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.appPrimary.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
}) {
|
|
||||||
Text(suggestion)
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(Color.appPrimary)
|
|
||||||
.padding(.horizontal, AppSpacing.md)
|
|
||||||
.padding(.vertical, AppSpacing.sm)
|
|
||||||
.background(Color.appPrimary.opacity(0.1))
|
|
||||||
.cornerRadius(AppRadius.md)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Continue button
|
// Continue button
|
||||||
Button(action: onContinue) {
|
Button(action: onContinue) {
|
||||||
HStack(spacing: AppSpacing.sm) {
|
HStack(spacing: 10) {
|
||||||
Text("That's Perfect!")
|
Text("That's Perfect!")
|
||||||
.font(.headline)
|
.font(.system(size: 17, weight: .bold))
|
||||||
.fontWeight(.bold)
|
|
||||||
|
|
||||||
Image(systemName: "arrow.right")
|
Image(systemName: "arrow.right")
|
||||||
.font(.headline)
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.background(
|
||||||
|
isValid
|
||||||
|
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing))
|
||||||
|
: AnyShapeStyle(Color.appTextSecondary.opacity(0.4))
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.naturalShadow(isValid ? .medium : .subtle)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceContinueButton)
|
||||||
.frame(height: 56)
|
.disabled(!isValid)
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
.background(
|
.padding(.bottom, OrganicSpacing.airy)
|
||||||
isValid
|
.animation(.easeInOut(duration: 0.2), value: isValid)
|
||||||
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing))
|
|
||||||
: AnyShapeStyle(Color.appTextSecondary.opacity(0.5))
|
|
||||||
)
|
|
||||||
.cornerRadius(AppRadius.lg)
|
|
||||||
.shadow(color: isValid ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8)
|
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceContinueButton)
|
|
||||||
.disabled(!isValid)
|
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
|
||||||
.padding(.bottom, AppSpacing.xxxl)
|
|
||||||
.animation(.easeInOut(duration: 0.2), value: isValid)
|
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
isAnimating = true
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
isTextFieldFocused = true
|
isTextFieldFocused = true
|
||||||
}
|
}
|
||||||
@@ -207,35 +272,44 @@ struct OnboardingNameResidenceView: View {
|
|||||||
var onBack: () -> Void
|
var onBack: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
ZStack {
|
||||||
// Navigation bar
|
WarmGradientBackground()
|
||||||
HStack {
|
|
||||||
Button(action: onBack) {
|
VStack(spacing: 0) {
|
||||||
Image(systemName: "chevron.left")
|
// Navigation bar
|
||||||
.font(.title2)
|
HStack {
|
||||||
.foregroundColor(Color.appPrimary)
|
Button(action: onBack) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
OnboardingProgressIndicator(currentStep: 2, totalSteps: 5)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Invisible spacer for alignment
|
||||||
|
Circle()
|
||||||
|
.fill(Color.clear)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
Spacer()
|
OnboardingNameResidenceContent(
|
||||||
|
residenceName: $residenceName,
|
||||||
OnboardingProgressIndicator(currentStep: 2, totalSteps: 5)
|
onContinue: onContinue
|
||||||
|
)
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Invisible spacer for alignment
|
|
||||||
Image(systemName: "chevron.left")
|
|
||||||
.font(.title2)
|
|
||||||
.opacity(0)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.lg)
|
|
||||||
.padding(.vertical, AppSpacing.md)
|
|
||||||
|
|
||||||
OnboardingNameResidenceContent(
|
|
||||||
residenceName: $residenceName,
|
|
||||||
onContinue: onContinue
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ struct OnboardingSubscriptionContent: View {
|
|||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var selectedPlan: PricingPlan = .yearly
|
@State private var selectedPlan: PricingPlan = .yearly
|
||||||
@State private var animateBadge = false
|
@State private var animateBadge = false
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
private let benefits: [SubscriptionBenefit] = [
|
private let benefits: [SubscriptionBenefit] = [
|
||||||
SubscriptionBenefit(
|
SubscriptionBenefit(
|
||||||
@@ -49,181 +50,233 @@ struct OnboardingSubscriptionContent: View {
|
|||||||
]
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ZStack {
|
||||||
VStack(spacing: AppSpacing.xl) {
|
WarmGradientBackground()
|
||||||
// Header with animated crown
|
|
||||||
VStack(spacing: AppSpacing.md) {
|
|
||||||
ZStack {
|
|
||||||
// Glow effect
|
|
||||||
Circle()
|
|
||||||
.fill(
|
|
||||||
RadialGradient(
|
|
||||||
colors: [Color.appAccent.opacity(0.3), Color.clear],
|
|
||||||
center: .center,
|
|
||||||
startRadius: 30,
|
|
||||||
endRadius: 100
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.frame(width: 180, height: 180)
|
|
||||||
.scaleEffect(animateBadge ? 1.1 : 1.0)
|
|
||||||
.animation(.easeInOut(duration: 2).repeatForever(autoreverses: true), value: animateBadge)
|
|
||||||
|
|
||||||
// Crown icon
|
// Decorative blobs
|
||||||
ZStack {
|
GeometryReader { geo in
|
||||||
Circle()
|
OrganicBlobShape(variation: 0)
|
||||||
.fill(
|
.fill(
|
||||||
LinearGradient(
|
RadialGradient(
|
||||||
colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange],
|
colors: [
|
||||||
startPoint: .topLeading,
|
Color.appAccent.opacity(0.08),
|
||||||
endPoint: .bottomTrailing
|
Color.appAccent.opacity(0.02),
|
||||||
)
|
Color.clear
|
||||||
)
|
],
|
||||||
.frame(width: 100, height: 100)
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
Image(systemName: "crown.fill")
|
endRadius: geo.size.width * 0.35
|
||||||
.font(.system(size: 44))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
.shadow(color: Color.appAccent.opacity(0.5), radius: 20, y: 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pro badge
|
|
||||||
HStack(spacing: AppSpacing.xs) {
|
|
||||||
Image(systemName: "sparkles")
|
|
||||||
.foregroundColor(Color.appAccent)
|
|
||||||
Text("CASERA PRO")
|
|
||||||
.font(.headline)
|
|
||||||
.fontWeight(.black)
|
|
||||||
.foregroundColor(Color.appAccent)
|
|
||||||
Image(systemName: "sparkles")
|
|
||||||
.foregroundColor(Color.appAccent)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, AppSpacing.lg)
|
|
||||||
.padding(.vertical, AppSpacing.sm)
|
|
||||||
.background(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.appAccent.opacity(0.15), Color(hex: "#FF9500")?.opacity(0.15) ?? Color.orange.opacity(0.15)],
|
|
||||||
startPoint: .leading,
|
|
||||||
endPoint: .trailing
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.clipShape(Capsule())
|
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.3)
|
||||||
|
.offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.05)
|
||||||
|
.blur(radius: 25)
|
||||||
|
|
||||||
Text("Take your home management\nto the next level")
|
OrganicBlobShape(variation: 2)
|
||||||
.font(.title2)
|
.fill(
|
||||||
.fontWeight(.bold)
|
RadialGradient(
|
||||||
.foregroundColor(Color.appTextPrimary)
|
colors: [
|
||||||
.multilineTextAlignment(.center)
|
Color.appPrimary.opacity(0.06),
|
||||||
.lineSpacing(4)
|
Color.appPrimary.opacity(0.01),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.25
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.2)
|
||||||
|
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.7)
|
||||||
|
.blur(radius: 20)
|
||||||
|
}
|
||||||
|
|
||||||
// Social proof
|
ScrollView(showsIndicators: false) {
|
||||||
HStack(spacing: AppSpacing.xs) {
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
ForEach(0..<5, id: \.self) { _ in
|
// Header with animated crown
|
||||||
Image(systemName: "star.fill")
|
VStack(spacing: 16) {
|
||||||
.font(.caption)
|
ZStack {
|
||||||
|
// Pulsing glow effect
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [Color.appAccent.opacity(0.25), Color.appAccent.opacity(0.05), Color.clear],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 30,
|
||||||
|
endRadius: 100
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 180, height: 180)
|
||||||
|
.scaleEffect(animateBadge ? 1.1 : 1.0)
|
||||||
|
.animation(.easeInOut(duration: 2).repeatForever(autoreverses: true), value: animateBadge)
|
||||||
|
|
||||||
|
// Crown icon
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
|
||||||
|
Image(systemName: "crown.fill")
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.naturalShadow(.pronounced)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pro badge
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.foregroundColor(Color.appAccent)
|
||||||
|
Text("CASERA PRO")
|
||||||
|
.font(.system(size: 14, weight: .black))
|
||||||
|
.foregroundColor(Color.appAccent)
|
||||||
|
Image(systemName: "sparkles")
|
||||||
.foregroundColor(Color.appAccent)
|
.foregroundColor(Color.appAccent)
|
||||||
}
|
}
|
||||||
Text("4.9")
|
.padding(.horizontal, 18)
|
||||||
.font(.subheadline)
|
.padding(.vertical, 10)
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
|
||||||
Text("• 10K+ homeowners")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, AppSpacing.lg)
|
|
||||||
|
|
||||||
// Benefits list with gradient icons
|
|
||||||
VStack(spacing: AppSpacing.sm) {
|
|
||||||
ForEach(benefits) { benefit in
|
|
||||||
SubscriptionBenefitRow(benefit: benefit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, AppSpacing.lg)
|
|
||||||
|
|
||||||
// Pricing plans
|
|
||||||
VStack(spacing: AppSpacing.md) {
|
|
||||||
Text("Choose your plan")
|
|
||||||
.font(.headline)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
|
||||||
|
|
||||||
// Yearly plan (best value)
|
|
||||||
PricingPlanCard(
|
|
||||||
plan: .yearly,
|
|
||||||
isSelected: selectedPlan == .yearly,
|
|
||||||
onSelect: { selectedPlan = .yearly }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Monthly plan
|
|
||||||
PricingPlanCard(
|
|
||||||
plan: .monthly,
|
|
||||||
isSelected: selectedPlan == .monthly,
|
|
||||||
onSelect: { selectedPlan = .monthly }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, AppSpacing.lg)
|
|
||||||
|
|
||||||
// CTA buttons
|
|
||||||
VStack(spacing: AppSpacing.md) {
|
|
||||||
Button(action: startFreeTrial) {
|
|
||||||
HStack(spacing: AppSpacing.sm) {
|
|
||||||
if isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
||||||
} else {
|
|
||||||
Text("Start 7-Day Free Trial")
|
|
||||||
.font(.headline)
|
|
||||||
.fontWeight(.bold)
|
|
||||||
|
|
||||||
Image(systemName: "arrow.right")
|
|
||||||
.font(.headline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: 56)
|
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
|
||||||
.background(
|
.background(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange],
|
colors: [Color.appAccent.opacity(0.15), Color(hex: "#FF9500")?.opacity(0.15) ?? Color.orange.opacity(0.15)],
|
||||||
startPoint: .leading,
|
startPoint: .leading,
|
||||||
endPoint: .trailing
|
endPoint: .trailing
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.cornerRadius(AppRadius.lg)
|
.clipShape(Capsule())
|
||||||
.shadow(color: Color.appAccent.opacity(0.4), radius: 15, y: 8)
|
|
||||||
}
|
|
||||||
.disabled(isLoading)
|
|
||||||
|
|
||||||
// Continue without
|
Text("Take your home management\nto the next level")
|
||||||
Button(action: {
|
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||||
onSubscribe()
|
.foregroundColor(Color.appTextPrimary)
|
||||||
}) {
|
.multilineTextAlignment(.center)
|
||||||
Text("Continue with Free")
|
.lineSpacing(4)
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legal text
|
// Social proof
|
||||||
VStack(spacing: AppSpacing.xs) {
|
HStack(spacing: 6) {
|
||||||
Text("7-day free trial, then \(selectedPlan == .yearly ? "$23.99/year" : "$2.99/month")")
|
ForEach(0..<5, id: \.self) { _ in
|
||||||
.font(.caption)
|
Image(systemName: "star.fill")
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.appAccent)
|
||||||
Text("Cancel anytime in Settings • No commitment")
|
}
|
||||||
.font(.caption)
|
Text("4.9")
|
||||||
.foregroundColor(Color.appTextSecondary.opacity(0.7))
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
Text("• 10K+ homeowners")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.multilineTextAlignment(.center)
|
.padding(.top, OrganicSpacing.comfortable)
|
||||||
.padding(.top, AppSpacing.xs)
|
|
||||||
|
// Benefits list card
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(benefits) { benefit in
|
||||||
|
OrganicSubscriptionBenefitRow(benefit: benefit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(OrganicSpacing.cozy)
|
||||||
|
.background(
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
|
||||||
|
GeometryReader { geo in
|
||||||
|
OrganicBlobShape(variation: 1)
|
||||||
|
.fill(Color.appAccent.opacity(colorScheme == .dark ? 0.06 : 0.04))
|
||||||
|
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.5)
|
||||||
|
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.3)
|
||||||
|
.blur(radius: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
GrainTexture(opacity: 0.015)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
.naturalShadow(.medium)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
|
// Pricing plans
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
Text("Choose your plan")
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
|
// Yearly plan (best value)
|
||||||
|
OrganicPricingPlanCard(
|
||||||
|
plan: .yearly,
|
||||||
|
isSelected: selectedPlan == .yearly,
|
||||||
|
onSelect: { selectedPlan = .yearly }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Monthly plan
|
||||||
|
OrganicPricingPlanCard(
|
||||||
|
plan: .monthly,
|
||||||
|
isSelected: selectedPlan == .monthly,
|
||||||
|
onSelect: { selectedPlan = .monthly }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
|
// CTA buttons
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
Button(action: startFreeTrial) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
} else {
|
||||||
|
Text("Start 7-Day Free Trial")
|
||||||
|
.font(.system(size: 17, weight: .bold))
|
||||||
|
|
||||||
|
Image(systemName: "arrow.right")
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.naturalShadow(.medium)
|
||||||
|
}
|
||||||
|
.disabled(isLoading)
|
||||||
|
|
||||||
|
// Continue without
|
||||||
|
Button(action: {
|
||||||
|
onSubscribe()
|
||||||
|
}) {
|
||||||
|
Text("Continue with Free")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legal text
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("7-day free trial, then \(selectedPlan == .yearly ? "$23.99/year" : "$2.99/month")")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
|
||||||
|
Text("Cancel anytime in Settings • No commitment")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary.opacity(0.7))
|
||||||
|
}
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
|
.padding(.bottom, OrganicSpacing.airy)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
|
||||||
.padding(.bottom, AppSpacing.xxxl)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
animateBadge = true
|
animateBadge = true
|
||||||
}
|
}
|
||||||
@@ -296,13 +349,15 @@ enum PricingPlan {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Pricing Plan Card
|
// MARK: - Organic Pricing Plan Card
|
||||||
|
|
||||||
struct PricingPlanCard: View {
|
private struct OrganicPricingPlanCard: View {
|
||||||
let plan: PricingPlan
|
let plan: PricingPlan
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
var onSelect: () -> Void
|
var onSelect: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onSelect) {
|
Button(action: onSelect) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -320,19 +375,17 @@ struct PricingPlanCard: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack(spacing: AppSpacing.sm) {
|
HStack(spacing: 8) {
|
||||||
Text(plan.title)
|
Text(plan.title)
|
||||||
.font(.headline)
|
.font(.system(size: 16, weight: .semibold))
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
if let savings = plan.savings {
|
if let savings = plan.savings {
|
||||||
Text(savings)
|
Text(savings)
|
||||||
.font(.caption)
|
.font(.system(size: 10, weight: .bold))
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, AppSpacing.sm)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 3)
|
||||||
.background(
|
.background(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [Color(hex: "#34C759") ?? .green, Color(hex: "#30D158") ?? .green],
|
colors: [Color(hex: "#34C759") ?? .green, Color(hex: "#30D158") ?? .green],
|
||||||
@@ -346,7 +399,7 @@ struct PricingPlanCard: View {
|
|||||||
|
|
||||||
if let monthlyEquivalent = plan.monthlyEquivalent {
|
if let monthlyEquivalent = plan.monthlyEquivalent {
|
||||||
Text(monthlyEquivalent)
|
Text(monthlyEquivalent)
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,28 +408,43 @@ struct PricingPlanCard: View {
|
|||||||
|
|
||||||
VStack(alignment: .trailing, spacing: 0) {
|
VStack(alignment: .trailing, spacing: 0) {
|
||||||
Text(plan.price)
|
Text(plan.price)
|
||||||
.font(.title3)
|
.font(.system(size: 20, weight: .bold))
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(isSelected ? Color.appAccent : Color.appTextPrimary)
|
.foregroundColor(isSelected ? Color.appAccent : Color.appTextPrimary)
|
||||||
|
|
||||||
Text(plan.period)
|
Text(plan.period)
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(AppSpacing.lg)
|
.padding(18)
|
||||||
.background(Color.appBackgroundSecondary)
|
.background(
|
||||||
.cornerRadius(AppRadius.lg)
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
|
||||||
|
if plan == .yearly {
|
||||||
|
GeometryReader { geo in
|
||||||
|
OrganicBlobShape(variation: 1)
|
||||||
|
.fill(Color.appAccent.opacity(colorScheme == .dark ? 0.06 : 0.04))
|
||||||
|
.frame(width: geo.size.width * 0.3, height: geo.size.height * 0.8)
|
||||||
|
.offset(x: geo.size.width * 0.75, y: 0)
|
||||||
|
.blur(radius: 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GrainTexture(opacity: 0.01)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: AppRadius.lg)
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
.stroke(
|
.stroke(
|
||||||
isSelected
|
isSelected
|
||||||
? LinearGradient(colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange], startPoint: .leading, endPoint: .trailing)
|
? LinearGradient(colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange], startPoint: .leading, endPoint: .trailing)
|
||||||
: LinearGradient(colors: [Color.clear, Color.clear], startPoint: .leading, endPoint: .trailing),
|
: LinearGradient(colors: [Color.appTextSecondary.opacity(0.2), Color.appTextSecondary.opacity(0.2)], startPoint: .leading, endPoint: .trailing),
|
||||||
lineWidth: 2
|
lineWidth: isSelected ? 2 : 1
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.shadow(color: isSelected ? Color.appAccent.opacity(0.15) : .clear, radius: 10, y: 4)
|
.naturalShadow(isSelected ? .medium : .subtle)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.animation(.easeInOut(duration: 0.2), value: isSelected)
|
.animation(.easeInOut(duration: 0.2), value: isSelected)
|
||||||
@@ -404,13 +472,13 @@ struct SubscriptionBenefit: Identifiable {
|
|||||||
let gradient: [Color]
|
let gradient: [Color]
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subscription Benefit Row
|
// MARK: - Organic Subscription Benefit Row
|
||||||
|
|
||||||
struct SubscriptionBenefitRow: View {
|
private struct OrganicSubscriptionBenefitRow: View {
|
||||||
let benefit: SubscriptionBenefit
|
let benefit: SubscriptionBenefit
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: AppSpacing.md) {
|
HStack(spacing: 14) {
|
||||||
// Gradient icon
|
// Gradient icon
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -421,22 +489,21 @@ struct SubscriptionBenefitRow: View {
|
|||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
Image(systemName: benefit.icon)
|
Image(systemName: benefit.icon)
|
||||||
.font(.title3)
|
.font(.system(size: 17, weight: .medium))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
.shadow(color: benefit.gradient[0].opacity(0.3), radius: 8, y: 4)
|
.naturalShadow(.subtle)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(benefit.title)
|
Text(benefit.title)
|
||||||
.font(.subheadline)
|
.font(.system(size: 14, weight: .semibold))
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
Text(benefit.description)
|
Text(benefit.description)
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
}
|
}
|
||||||
@@ -444,12 +511,11 @@ struct SubscriptionBenefitRow: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .bold))
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(benefit.gradient[0])
|
.foregroundColor(benefit.gradient[0])
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.md)
|
.padding(.horizontal, 4)
|
||||||
.padding(.vertical, AppSpacing.sm)
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,56 +56,58 @@ struct OnboardingValuePropsContent: View {
|
|||||||
]
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
ZStack {
|
||||||
// Feature cards in a tab view
|
WarmGradientBackground()
|
||||||
TabView(selection: $currentPage) {
|
|
||||||
ForEach(Array(features.enumerated()), id: \.offset) { index, feature in
|
|
||||||
FeatureCard(feature: feature, isActive: currentPage == index)
|
|
||||||
.tag(index)
|
|
||||||
.padding(.horizontal, AppSpacing.lg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
|
|
||||||
// Custom page indicator
|
VStack(spacing: 0) {
|
||||||
HStack(spacing: AppSpacing.sm) {
|
// Feature cards in a tab view
|
||||||
ForEach(0..<features.count, id: \.self) { index in
|
TabView(selection: $currentPage) {
|
||||||
Capsule()
|
ForEach(Array(features.enumerated()), id: \.offset) { index, feature in
|
||||||
.fill(currentPage == index ? Color.appPrimary : Color.appTextSecondary.opacity(0.3))
|
OrganicFeatureCard(feature: feature, isActive: currentPage == index)
|
||||||
.frame(width: currentPage == index ? 24 : 8, height: 8)
|
.tag(index)
|
||||||
.animation(.spring(response: 0.3), value: currentPage)
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
.padding(.bottom, AppSpacing.xl)
|
.frame(maxHeight: .infinity)
|
||||||
|
|
||||||
// Continue button
|
// Custom page indicator
|
||||||
Button(action: onContinue) {
|
HStack(spacing: 10) {
|
||||||
HStack(spacing: AppSpacing.sm) {
|
ForEach(0..<features.count, id: \.self) { index in
|
||||||
Text("I'm Ready!")
|
Capsule()
|
||||||
.font(.headline)
|
.fill(currentPage == index ? Color.appPrimary : Color.appTextSecondary.opacity(0.25))
|
||||||
.fontWeight(.bold)
|
.frame(width: currentPage == index ? 28 : 8, height: 8)
|
||||||
|
.animation(.spring(response: 0.3), value: currentPage)
|
||||||
Image(systemName: "arrow.right")
|
}
|
||||||
.font(.headline)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.padding(.bottom, OrganicSpacing.comfortable)
|
||||||
.frame(height: 56)
|
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
// Continue button
|
||||||
.background(
|
Button(action: onContinue) {
|
||||||
LinearGradient(
|
HStack(spacing: 10) {
|
||||||
colors: [Color.appPrimary, Color.appSecondary],
|
Text("I'm Ready!")
|
||||||
startPoint: .leading,
|
.font(.system(size: 17, weight: .bold))
|
||||||
endPoint: .trailing
|
|
||||||
|
Image(systemName: "arrow.right")
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary, Color.appSecondary],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.cornerRadius(AppRadius.lg)
|
.naturalShadow(.medium)
|
||||||
.shadow(color: Color.appPrimary.opacity(0.4), radius: 15, y: 8)
|
}
|
||||||
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
|
.padding(.bottom, OrganicSpacing.airy)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
|
||||||
.padding(.bottom, AppSpacing.xxxl)
|
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,17 +124,18 @@ struct FeatureHighlight: Identifiable {
|
|||||||
let statLabel: String
|
let statLabel: String
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Feature Card
|
// MARK: - Organic Feature Card
|
||||||
|
|
||||||
struct FeatureCard: View {
|
struct OrganicFeatureCard: View {
|
||||||
let feature: FeatureHighlight
|
let feature: FeatureHighlight
|
||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
|
|
||||||
@State private var appeared = false
|
@State private var appeared = false
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: AppSpacing.lg) {
|
VStack(spacing: OrganicSpacing.cozy) {
|
||||||
Spacer(minLength: AppSpacing.md)
|
Spacer(minLength: 16)
|
||||||
|
|
||||||
// Large icon with gradient background
|
// Large icon with gradient background
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -140,46 +143,46 @@ struct FeatureCard: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(
|
.fill(
|
||||||
RadialGradient(
|
RadialGradient(
|
||||||
colors: [feature.gradient[0].opacity(0.3), Color.clear],
|
colors: [feature.gradient[0].opacity(0.25), Color.clear],
|
||||||
center: .center,
|
center: .center,
|
||||||
startRadius: 30,
|
startRadius: 30,
|
||||||
endRadius: 80
|
endRadius: 90
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.frame(width: 160, height: 160)
|
.frame(width: 180, height: 180)
|
||||||
.scaleEffect(appeared ? 1 : 0.8)
|
.scaleEffect(appeared ? 1 : 0.8)
|
||||||
.opacity(appeared ? 1 : 0)
|
.opacity(appeared ? 1 : 0)
|
||||||
|
|
||||||
// Icon circle
|
// Icon circle
|
||||||
Circle()
|
ZStack {
|
||||||
.fill(
|
Circle()
|
||||||
LinearGradient(
|
.fill(
|
||||||
colors: feature.gradient,
|
LinearGradient(
|
||||||
startPoint: .topLeading,
|
colors: feature.gradient,
|
||||||
endPoint: .bottomTrailing
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.frame(width: 100, height: 100)
|
||||||
.frame(width: 100, height: 100)
|
|
||||||
.shadow(color: feature.gradient[0].opacity(0.5), radius: 15, y: 8)
|
|
||||||
|
|
||||||
Image(systemName: feature.icon)
|
Image(systemName: feature.icon)
|
||||||
.font(.system(size: 44))
|
.font(.system(size: 44))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.naturalShadow(.pronounced)
|
||||||
}
|
}
|
||||||
.scaleEffect(appeared ? 1 : 0.5)
|
.scaleEffect(appeared ? 1 : 0.5)
|
||||||
.animation(.spring(response: 0.5, dampingFraction: 0.7), value: appeared)
|
.animation(.spring(response: 0.5, dampingFraction: 0.7), value: appeared)
|
||||||
|
|
||||||
// Text content
|
// Text content
|
||||||
VStack(spacing: AppSpacing.sm) {
|
VStack(spacing: 10) {
|
||||||
Text(feature.title)
|
Text(feature.title)
|
||||||
.font(.title2)
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
Text(feature.subtitle)
|
Text(feature.subtitle)
|
||||||
.font(.subheadline)
|
.font(.system(size: 15, weight: .semibold))
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundStyle(
|
.foregroundStyle(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: feature.gradient,
|
colors: feature.gradient,
|
||||||
@@ -190,21 +193,21 @@ struct FeatureCard: View {
|
|||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
Text(feature.description)
|
Text(feature.description)
|
||||||
.font(.subheadline)
|
.font(.system(size: 15, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.lineSpacing(3)
|
.lineSpacing(4)
|
||||||
.padding(.horizontal, AppSpacing.sm)
|
.padding(.horizontal, 8)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
.opacity(appeared ? 1 : 0)
|
.opacity(appeared ? 1 : 0)
|
||||||
.offset(y: appeared ? 0 : 20)
|
.offset(y: appeared ? 0 : 20)
|
||||||
.animation(.easeOut(duration: 0.4).delay(0.2), value: appeared)
|
.animation(.easeOut(duration: 0.4).delay(0.2), value: appeared)
|
||||||
|
|
||||||
// Stat highlight
|
// Stat highlight card
|
||||||
VStack(spacing: AppSpacing.xs) {
|
VStack(spacing: 6) {
|
||||||
Text(feature.statNumber)
|
Text(feature.statNumber)
|
||||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
.font(.system(size: 36, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(
|
.foregroundStyle(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: feature.gradient,
|
colors: feature.gradient,
|
||||||
@@ -214,22 +217,36 @@ struct FeatureCard: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Text(feature.statLabel)
|
Text(feature.statLabel)
|
||||||
.font(.caption)
|
.font(.system(size: 13, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.lg)
|
.padding(.horizontal, OrganicSpacing.cozy)
|
||||||
.padding(.vertical, AppSpacing.md)
|
.padding(.vertical, 16)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: AppRadius.lg)
|
ZStack {
|
||||||
.fill(Color.appBackgroundSecondary)
|
Color.appBackgroundSecondary
|
||||||
|
|
||||||
|
// Subtle blob accent
|
||||||
|
GeometryReader { geo in
|
||||||
|
OrganicBlobShape(variation: 1)
|
||||||
|
.fill(feature.gradient[0].opacity(colorScheme == .dark ? 0.06 : 0.04))
|
||||||
|
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.8)
|
||||||
|
.offset(x: geo.size.width * 0.6, y: 0)
|
||||||
|
.blur(radius: 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
GrainTexture(opacity: 0.015)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||||
|
.naturalShadow(.subtle)
|
||||||
.opacity(appeared ? 1 : 0)
|
.opacity(appeared ? 1 : 0)
|
||||||
.animation(.easeOut(duration: 0.4).delay(0.4), value: appeared)
|
.animation(.easeOut(duration: 0.4).delay(0.4), value: appeared)
|
||||||
|
|
||||||
Spacer(minLength: AppSpacing.md)
|
Spacer(minLength: 16)
|
||||||
}
|
}
|
||||||
.onChange(of: isActive) { _, newValue in
|
.onChange(of: isActive) { _, newValue in
|
||||||
if newValue {
|
if newValue {
|
||||||
@@ -257,34 +274,42 @@ struct OnboardingValuePropsView: View {
|
|||||||
var onBack: () -> Void
|
var onBack: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
ZStack {
|
||||||
// Navigation bar
|
WarmGradientBackground()
|
||||||
HStack {
|
|
||||||
Button(action: onBack) {
|
VStack(spacing: 0) {
|
||||||
Image(systemName: "chevron.left")
|
// Navigation bar
|
||||||
.font(.title2)
|
HStack {
|
||||||
.foregroundColor(Color.appPrimary)
|
Button(action: onBack) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
OnboardingProgressIndicator(currentStep: 1, totalSteps: 5)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: onSkip) {
|
||||||
|
Text("Skip")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
Spacer()
|
OnboardingValuePropsContent(onContinue: onContinue)
|
||||||
|
|
||||||
OnboardingProgressIndicator(currentStep: 1, totalSteps: 5)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: onSkip) {
|
|
||||||
Text("Skip")
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.lg)
|
|
||||||
.padding(.vertical, AppSpacing.md)
|
|
||||||
|
|
||||||
OnboardingValuePropsContent(onContinue: onContinue)
|
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,133 +8,232 @@ struct OnboardingVerifyEmailContent: View {
|
|||||||
@StateObject private var viewModel = VerifyEmailViewModel()
|
@StateObject private var viewModel = VerifyEmailViewModel()
|
||||||
@FocusState private var isCodeFieldFocused: Bool
|
@FocusState private var isCodeFieldFocused: Bool
|
||||||
@State private var hasCalledOnVerified = false
|
@State private var hasCalledOnVerified = false
|
||||||
|
@State private var isAnimating = false
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
ZStack {
|
||||||
Spacer()
|
WarmGradientBackground()
|
||||||
|
|
||||||
// Content
|
// Decorative blobs
|
||||||
VStack(spacing: AppSpacing.xl) {
|
GeometryReader { geo in
|
||||||
// Icon
|
OrganicBlobShape(variation: 0)
|
||||||
ZStack {
|
.fill(
|
||||||
Circle()
|
RadialGradient(
|
||||||
.fill(Color.appPrimary.opacity(0.1))
|
colors: [
|
||||||
.frame(width: 100, height: 100)
|
Color.appPrimary.opacity(0.06),
|
||||||
|
Color.appPrimary.opacity(0.01),
|
||||||
Image(systemName: "envelope.badge.fill")
|
Color.clear
|
||||||
.font(.system(size: 44))
|
],
|
||||||
.foregroundStyle(Color.appPrimary.gradient)
|
center: .center,
|
||||||
}
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.3
|
||||||
// Title
|
)
|
||||||
VStack(spacing: AppSpacing.sm) {
|
|
||||||
Text("Verify your email")
|
|
||||||
.font(.title2)
|
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyEmailTitle)
|
|
||||||
|
|
||||||
Text("We sent a 6-digit code to your email address. Enter it below to verify your account.")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code input
|
|
||||||
VStack(alignment: .leading, spacing: AppSpacing.xs) {
|
|
||||||
HStack(spacing: AppSpacing.sm) {
|
|
||||||
Image(systemName: "key.fill")
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
.frame(width: 20)
|
|
||||||
|
|
||||||
TextField("Enter 6-digit code", text: $viewModel.code)
|
|
||||||
.keyboardType(.numberPad)
|
|
||||||
.textContentType(.oneTimeCode)
|
|
||||||
.focused($isCodeFieldFocused)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField)
|
|
||||||
.keyboardDismissToolbar()
|
|
||||||
.onChange(of: viewModel.code) { _, newValue in
|
|
||||||
// Limit to 6 digits
|
|
||||||
if newValue.count > 6 {
|
|
||||||
viewModel.code = String(newValue.prefix(6))
|
|
||||||
}
|
|
||||||
// Auto-verify when 6 digits entered
|
|
||||||
if newValue.count == 6 {
|
|
||||||
viewModel.verifyEmail()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(AppSpacing.md)
|
|
||||||
.background(Color.appBackgroundSecondary)
|
|
||||||
.cornerRadius(AppRadius.md)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: AppRadius.md)
|
|
||||||
.stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
|
|
||||||
)
|
)
|
||||||
}
|
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.3)
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
.offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.1)
|
||||||
|
.blur(radius: 20)
|
||||||
|
|
||||||
// Error message
|
OrganicBlobShape(variation: 2)
|
||||||
if let error = viewModel.errorMessage {
|
.fill(
|
||||||
HStack(spacing: AppSpacing.sm) {
|
RadialGradient(
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
colors: [
|
||||||
.foregroundColor(Color.appError)
|
Color.appAccent.opacity(0.05),
|
||||||
Text(error)
|
Color.appAccent.opacity(0.01),
|
||||||
.font(.callout)
|
Color.clear
|
||||||
.foregroundColor(Color.appError)
|
],
|
||||||
Spacer()
|
center: .center,
|
||||||
}
|
startRadius: 0,
|
||||||
.padding(AppSpacing.md)
|
endRadius: geo.size.width * 0.25
|
||||||
.background(Color.appError.opacity(0.1))
|
)
|
||||||
.cornerRadius(AppRadius.md)
|
)
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.25)
|
||||||
}
|
.offset(x: geo.size.width * 0.6, y: geo.size.height * 0.65)
|
||||||
|
.blur(radius: 15)
|
||||||
// Loading indicator
|
|
||||||
if viewModel.isLoading {
|
|
||||||
HStack {
|
|
||||||
ProgressView()
|
|
||||||
Text("Verifying...")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resend code hint
|
|
||||||
Text("Didn't receive a code? Check your spam folder or re-register")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
VStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
// Verify button
|
// Content
|
||||||
Button(action: {
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
viewModel.verifyEmail()
|
// Icon with pulsing glow
|
||||||
}) {
|
ZStack {
|
||||||
Text("Verify")
|
Circle()
|
||||||
.font(.headline)
|
.fill(
|
||||||
.fontWeight(.semibold)
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(0.15),
|
||||||
|
Color.appPrimary.opacity(0.05),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 70
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 140, height: 140)
|
||||||
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
|
.animation(
|
||||||
|
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
|
||||||
|
value: isAnimating
|
||||||
|
)
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 90, height: 90)
|
||||||
|
|
||||||
|
Image(systemName: "envelope.badge.fill")
|
||||||
|
.font(.system(size: 40, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.naturalShadow(.pronounced)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Text("Verify your email")
|
||||||
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyEmailTitle)
|
||||||
|
|
||||||
|
Text("We sent a 6-digit code to your email address. Enter it below to verify your account.")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineSpacing(4)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code input card
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
|
Image(systemName: "key.fill")
|
||||||
|
.font(.system(size: 17, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Enter 6-digit code", text: $viewModel.code)
|
||||||
|
.font(.system(size: 20, weight: .semibold, design: .monospaced))
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.textContentType(.oneTimeCode)
|
||||||
|
.focused($isCodeFieldFocused)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField)
|
||||||
|
.keyboardDismissToolbar()
|
||||||
|
.onChange(of: viewModel.code) { _, newValue in
|
||||||
|
// Limit to 6 digits
|
||||||
|
if newValue.count > 6 {
|
||||||
|
viewModel.code = String(newValue.prefix(6))
|
||||||
|
}
|
||||||
|
// Auto-verify when 6 digits entered
|
||||||
|
if newValue.count == 6 {
|
||||||
|
viewModel.verifyEmail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(18)
|
||||||
|
.background(
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
GrainTexture(opacity: 0.01)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.2), lineWidth: 2)
|
||||||
|
)
|
||||||
|
.naturalShadow(.medium)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.appError.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading indicator
|
||||||
|
if viewModel.isLoading {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
.tint(Color.appPrimary)
|
||||||
|
Text("Verifying...")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resend code hint
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "info.circle.fill")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Color.appTextSecondary.opacity(0.7))
|
||||||
|
|
||||||
|
Text("Didn't receive a code? Check your spam folder or re-register")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Verify button
|
||||||
|
Button(action: {
|
||||||
|
viewModel.verifyEmail()
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
}
|
||||||
|
Text(viewModel.isLoading ? "Verifying..." : "Verify")
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 56)
|
.frame(height: 56)
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
.background(
|
.background(
|
||||||
viewModel.code.count == 6 && !viewModel.isLoading
|
viewModel.code.count == 6 && !viewModel.isLoading
|
||||||
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
: AnyShapeStyle(Color.appTextSecondary)
|
: AnyShapeStyle(Color.appTextSecondary.opacity(0.4))
|
||||||
)
|
)
|
||||||
.cornerRadius(AppRadius.md)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.shadow(color: viewModel.code.count == 6 ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5)
|
.naturalShadow(viewModel.code.count == 6 ? .medium : .subtle)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyButton)
|
||||||
|
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
|
||||||
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
|
.padding(.bottom, OrganicSpacing.airy)
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyButton)
|
|
||||||
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
|
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
|
||||||
.padding(.bottom, AppSpacing.xxxl)
|
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
print("🏠 ONBOARDING: OnboardingVerifyEmailContent appeared")
|
print("🏠 ONBOARDING: OnboardingVerifyEmailContent appeared")
|
||||||
|
isAnimating = true
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
isCodeFieldFocused = true
|
isCodeFieldFocused = true
|
||||||
}
|
}
|
||||||
@@ -159,33 +258,40 @@ struct OnboardingVerifyEmailView: View {
|
|||||||
var onLogout: () -> Void
|
var onLogout: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
ZStack {
|
||||||
// Navigation bar
|
WarmGradientBackground()
|
||||||
HStack {
|
|
||||||
// Logout option
|
VStack(spacing: 0) {
|
||||||
Button(action: onLogout) {
|
// Navigation bar
|
||||||
Text("Back")
|
HStack {
|
||||||
.font(.subheadline)
|
// Logout option
|
||||||
|
Button(action: onLogout) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "arrow.left")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
Text("Back")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
}
|
||||||
.foregroundColor(Color.appPrimary)
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
OnboardingProgressIndicator(currentStep: 4, totalSteps: 5)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Invisible spacer for alignment
|
||||||
|
Text("Back")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.opacity(0)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
Spacer()
|
OnboardingVerifyEmailContent(onVerified: onVerified)
|
||||||
|
|
||||||
OnboardingProgressIndicator(currentStep: 4, totalSteps: 5)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Invisible spacer for alignment
|
|
||||||
Text("Back")
|
|
||||||
.font(.subheadline)
|
|
||||||
.opacity(0)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.lg)
|
|
||||||
.padding(.vertical, AppSpacing.md)
|
|
||||||
|
|
||||||
OnboardingVerifyEmailContent(onVerified: onVerified)
|
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,104 +7,190 @@ struct OnboardingWelcomeView: View {
|
|||||||
var onLogin: () -> Void
|
var onLogin: () -> Void
|
||||||
|
|
||||||
@State private var showingLoginSheet = false
|
@State private var showingLoginSheet = false
|
||||||
|
@State private var isAnimating = false
|
||||||
|
@State private var iconScale: CGFloat = 0.8
|
||||||
|
@State private var iconOpacity: Double = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
ZStack {
|
||||||
Spacer()
|
WarmGradientBackground()
|
||||||
|
|
||||||
// Hero section
|
// Decorative blobs
|
||||||
VStack(spacing: AppSpacing.xl) {
|
GeometryReader { geo in
|
||||||
// App icon
|
OrganicBlobShape(variation: 0)
|
||||||
Image("icon")
|
.fill(
|
||||||
.resizable()
|
RadialGradient(
|
||||||
.scaledToFit()
|
colors: [
|
||||||
.frame(width: 120, height: 120)
|
Color.appPrimary.opacity(0.08),
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.xxl))
|
Color.appPrimary.opacity(0.02),
|
||||||
.shadow(color: Color.appPrimary.opacity(0.3), radius: 20, y: 10)
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.4
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 0.7, height: geo.size.height * 0.4)
|
||||||
|
.offset(x: -geo.size.width * 0.2, y: geo.size.height * 0.1)
|
||||||
|
.blur(radius: 30)
|
||||||
|
|
||||||
// Welcome text
|
OrganicBlobShape(variation: 1)
|
||||||
VStack(spacing: AppSpacing.sm) {
|
.fill(
|
||||||
Text("Welcome to Casera")
|
RadialGradient(
|
||||||
.font(.largeTitle)
|
colors: [
|
||||||
.fontWeight(.bold)
|
Color.appAccent.opacity(0.06),
|
||||||
.foregroundColor(Color.appTextPrimary)
|
Color.appAccent.opacity(0.01),
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle)
|
Color.clear
|
||||||
|
],
|
||||||
Text("Your home maintenance companion")
|
center: .center,
|
||||||
.font(.title3)
|
startRadius: 0,
|
||||||
.foregroundColor(Color.appTextSecondary)
|
endRadius: geo.size.width * 0.3
|
||||||
.multilineTextAlignment(.center)
|
)
|
||||||
}
|
)
|
||||||
|
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.3)
|
||||||
|
.offset(x: geo.size.width * 0.6, y: geo.size.height * 0.65)
|
||||||
|
.blur(radius: 25)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
VStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
// Action buttons
|
// Hero section
|
||||||
VStack(spacing: AppSpacing.md) {
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
// Primary CTA - Start Fresh
|
// Animated icon with glow
|
||||||
Button(action: onStartFresh) {
|
ZStack {
|
||||||
HStack(spacing: AppSpacing.sm) {
|
// Outer pulsing glow
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(0.2),
|
||||||
|
Color.appPrimary.opacity(0.05),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 100
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 200, height: 200)
|
||||||
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
|
.animation(
|
||||||
|
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
|
||||||
|
value: isAnimating
|
||||||
|
)
|
||||||
|
|
||||||
|
// App icon
|
||||||
Image("icon")
|
Image("icon")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 24, height: 24)
|
.frame(width: 120, height: 120)
|
||||||
Text("Start Fresh")
|
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||||
.font(.headline)
|
.naturalShadow(.pronounced)
|
||||||
.fontWeight(.semibold)
|
.scaleEffect(iconScale)
|
||||||
|
.opacity(iconOpacity)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: 56)
|
// Welcome text
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
VStack(spacing: 10) {
|
||||||
.background(
|
Text("Welcome to Casera")
|
||||||
LinearGradient(
|
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||||
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
|
.foregroundColor(Color.appTextPrimary)
|
||||||
startPoint: .topLeading,
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle)
|
||||||
endPoint: .bottomTrailing
|
|
||||||
|
Text("Your home maintenance companion")
|
||||||
|
.font(.system(size: 17, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
// Primary CTA - Start Fresh
|
||||||
|
Button(action: onStartFresh) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image("icon")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
Text("Start Fresh")
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.cornerRadius(AppRadius.md)
|
.naturalShadow(.medium)
|
||||||
.shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5)
|
|
||||||
}
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton)
|
|
||||||
|
|
||||||
// Secondary CTA - Join Existing
|
|
||||||
Button(action: onJoinExisting) {
|
|
||||||
HStack(spacing: AppSpacing.sm) {
|
|
||||||
Image(systemName: "person.2.fill")
|
|
||||||
.font(.title3)
|
|
||||||
Text("I have a code to join")
|
|
||||||
.font(.headline)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton)
|
||||||
.frame(height: 56)
|
|
||||||
.foregroundColor(Color.appPrimary)
|
|
||||||
.background(Color.appPrimary.opacity(0.1))
|
|
||||||
.cornerRadius(AppRadius.md)
|
|
||||||
}
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton)
|
|
||||||
|
|
||||||
// Returning user login
|
// Secondary CTA - Join Existing
|
||||||
Button(action: {
|
Button(action: onJoinExisting) {
|
||||||
showingLoginSheet = true
|
HStack(spacing: 12) {
|
||||||
}) {
|
Image(systemName: "person.2.fill")
|
||||||
Text("Already have an account? Log in")
|
.font(.system(size: 18, weight: .medium))
|
||||||
.font(.subheadline)
|
Text("I have a code to join")
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.font(.system(size: 17, weight: .medium))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
.background(Color.appPrimary.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.stroke(Color.appPrimary.opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton)
|
||||||
|
|
||||||
|
// Returning user login
|
||||||
|
Button(action: {
|
||||||
|
showingLoginSheet = true
|
||||||
|
}) {
|
||||||
|
Text("Already have an account? Log in")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton)
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton)
|
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||||
.padding(.top, AppSpacing.sm)
|
.padding(.bottom, OrganicSpacing.airy)
|
||||||
|
|
||||||
|
// Floating leaves decoration
|
||||||
|
HStack(spacing: 50) {
|
||||||
|
FloatingLeaf(delay: 0, size: 16, color: Color.appPrimary)
|
||||||
|
FloatingLeaf(delay: 0.5, size: 12, color: Color.appAccent)
|
||||||
|
FloatingLeaf(delay: 1.0, size: 18, color: Color.appPrimary)
|
||||||
|
}
|
||||||
|
.opacity(0.5)
|
||||||
|
.padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
|
||||||
.padding(.bottom, AppSpacing.xxxl)
|
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.sheet(isPresented: $showingLoginSheet) {
|
.sheet(isPresented: $showingLoginSheet) {
|
||||||
LoginView(onLoginSuccess: {
|
LoginView(onLoginSuccess: {
|
||||||
showingLoginSheet = false
|
showingLoginSheet = false
|
||||||
onLogin()
|
onLogin()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
isAnimating = true
|
||||||
|
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
|
||||||
|
iconScale = 1.0
|
||||||
|
iconOpacity = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,120 +7,213 @@ struct ForgotPasswordView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
Form {
|
ZStack {
|
||||||
// Header Section
|
WarmGradientBackground()
|
||||||
Section {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Image(systemName: "key.fill")
|
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundStyle(Color.appPrimary.gradient)
|
|
||||||
.padding(.vertical)
|
|
||||||
|
|
||||||
Text("Forgot Password?")
|
ScrollView(showsIndicators: false) {
|
||||||
.font(.title2)
|
VStack(spacing: OrganicSpacing.spacious) {
|
||||||
.fontWeight(.bold)
|
Spacer()
|
||||||
|
.frame(height: OrganicSpacing.comfortable)
|
||||||
|
|
||||||
Text("Enter your email address and we'll send you a verification code")
|
// Hero Section
|
||||||
.font(.subheadline)
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
.foregroundColor(Color.appTextSecondary)
|
ZStack {
|
||||||
.multilineTextAlignment(.center)
|
Circle()
|
||||||
}
|
.fill(
|
||||||
.frame(maxWidth: .infinity)
|
RadialGradient(
|
||||||
.padding(.vertical)
|
colors: [
|
||||||
}
|
Color.appPrimary.opacity(0.15),
|
||||||
.listRowBackground(Color.clear)
|
Color.appPrimary.opacity(0.05),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 60
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
// Email Input Section
|
Image(systemName: "key.fill")
|
||||||
Section {
|
.font(.system(size: 48, weight: .medium))
|
||||||
TextField("Email Address", text: $viewModel.email)
|
.foregroundColor(Color.appPrimary)
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.keyboardType(.emailAddress)
|
|
||||||
.focused($isEmailFocused)
|
|
||||||
.submitLabel(.go)
|
|
||||||
.onSubmit {
|
|
||||||
viewModel.requestPasswordReset()
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.email) { _, _ in
|
|
||||||
viewModel.clearError()
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Email")
|
|
||||||
} footer: {
|
|
||||||
Text("We'll send a 6-digit verification code to this address")
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
|
|
||||||
// Error/Success Messages
|
|
||||||
if let errorMessage = viewModel.errorMessage {
|
|
||||||
Section {
|
|
||||||
Label {
|
|
||||||
Text(errorMessage)
|
|
||||||
.foregroundColor(Color.appError)
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundColor(Color.appError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let successMessage = viewModel.successMessage {
|
|
||||||
Section {
|
|
||||||
Label {
|
|
||||||
Text(successMessage)
|
|
||||||
.foregroundColor(Color.appAccent)
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(Color.appAccent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send Code Button
|
|
||||||
Section {
|
|
||||||
Button(action: {
|
|
||||||
viewModel.requestPasswordReset()
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
if viewModel.isLoading {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
Label("Send Reset Code", systemImage: "envelope.fill")
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
}
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(viewModel.email.isEmpty || viewModel.isLoading)
|
|
||||||
|
|
||||||
Button(action: {
|
VStack(spacing: 8) {
|
||||||
dismiss()
|
Text("Forgot Password?")
|
||||||
}) {
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
HStack {
|
.foregroundColor(Color.appTextPrimary)
|
||||||
Spacer()
|
|
||||||
Text("Back to Login")
|
Text("Enter your email address and we'll send you a verification code")
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.font(.system(size: 15, weight: .medium))
|
||||||
Spacer()
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Form Card
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Email Field
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("EMAIL")
|
||||||
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.tracking(1.2)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
Image(systemName: "envelope.fill")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Email Address", text: $viewModel.email)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.focused($isEmailFocused)
|
||||||
|
.submitLabel(.go)
|
||||||
|
.onSubmit {
|
||||||
|
viewModel.requestPasswordReset()
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.email) { _, _ in
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.stroke(isEmailFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
||||||
|
)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: isEmailFocused)
|
||||||
|
|
||||||
|
Text("We'll send a 6-digit verification code to this address")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Message
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appError.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success Message
|
||||||
|
if let successMessage = viewModel.successMessage {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(Color.appAccent)
|
||||||
|
Text(successMessage)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appAccent)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appAccent.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Code Button
|
||||||
|
Button(action: {
|
||||||
|
viewModel.requestPasswordReset()
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "envelope.fill")
|
||||||
|
}
|
||||||
|
Text(viewModel.isLoading ? "Sending..." : "Send Reset Code")
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.background(
|
||||||
|
!viewModel.email.isEmpty && !viewModel.isLoading
|
||||||
|
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
|
: AnyShapeStyle(Color.appTextSecondary)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.shadow(
|
||||||
|
color: !viewModel.email.isEmpty && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
|
||||||
|
radius: 10,
|
||||||
|
y: 5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.disabled(viewModel.email.isEmpty || viewModel.isLoading)
|
||||||
|
|
||||||
|
// Back to Login
|
||||||
|
Button(action: { dismiss() }) {
|
||||||
|
Text("Back to Login")
|
||||||
|
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.padding(OrganicSpacing.cozy)
|
||||||
|
.background(OrganicFormCardBackground())
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
|
||||||
|
.naturalShadow(.pronounced)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.navigationTitle("Reset Password")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
isEmailFocused = true
|
isEmailFocused = true
|
||||||
}
|
}
|
||||||
.handleErrors(
|
}
|
||||||
error: viewModel.errorMessage,
|
}
|
||||||
onRetry: { viewModel.requestPasswordReset() }
|
}
|
||||||
)
|
|
||||||
|
// MARK: - Organic Form Card Background
|
||||||
|
|
||||||
|
private struct OrganicFormCardBackground: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
|
||||||
|
GeometryReader { geo in
|
||||||
|
OrganicBlobShape(variation: 0)
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
|
||||||
|
Color.appPrimary.opacity(0.01)
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.5
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.5)
|
||||||
|
.offset(x: geo.size.width * 0.45, y: -geo.size.height * 0.1)
|
||||||
|
.blur(radius: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
GrainTexture(opacity: 0.015)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,244 +12,6 @@ struct ResetPasswordView: View {
|
|||||||
case newPassword, confirmPassword
|
case newPassword, confirmPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
Form {
|
|
||||||
// Header Section
|
|
||||||
Section {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Image(systemName: "lock.rotation")
|
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundStyle(Color.appPrimary.gradient)
|
|
||||||
.padding(.vertical)
|
|
||||||
|
|
||||||
Text("Set New Password")
|
|
||||||
.font(.title2)
|
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
|
||||||
|
|
||||||
Text("Create a strong password to secure your account")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical)
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
|
|
||||||
// Password Requirements
|
|
||||||
Section {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: viewModel.newPassword.count >= 8 ? "checkmark.circle.fill" : "circle")
|
|
||||||
.foregroundColor(viewModel.newPassword.count >= 8 ? Color.appPrimary : Color.appTextSecondary)
|
|
||||||
Text("At least 8 characters")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: hasLetter ? "checkmark.circle.fill" : "circle")
|
|
||||||
.foregroundColor(hasLetter ? Color.appPrimary : Color.appTextSecondary)
|
|
||||||
Text("Contains letters")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: hasNumber ? "checkmark.circle.fill" : "circle")
|
|
||||||
.foregroundColor(hasNumber ? Color.appPrimary : Color.appTextSecondary)
|
|
||||||
Text("Contains numbers")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: passwordsMatch ? "checkmark.circle.fill" : "circle")
|
|
||||||
.foregroundColor(passwordsMatch ? Color.appPrimary : Color.appTextSecondary)
|
|
||||||
Text("Passwords match")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Password Requirements")
|
|
||||||
}
|
|
||||||
|
|
||||||
// New Password Input
|
|
||||||
Section {
|
|
||||||
HStack {
|
|
||||||
if isNewPasswordVisible {
|
|
||||||
TextField("Enter new password", text: $viewModel.newPassword)
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.focused($focusedField, equals: .newPassword)
|
|
||||||
.submitLabel(.next)
|
|
||||||
.onSubmit {
|
|
||||||
focusedField = .confirmPassword
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
SecureField("Enter new password", text: $viewModel.newPassword)
|
|
||||||
.focused($focusedField, equals: .newPassword)
|
|
||||||
.submitLabel(.next)
|
|
||||||
.onSubmit {
|
|
||||||
focusedField = .confirmPassword
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
isNewPasswordVisible.toggle()
|
|
||||||
}) {
|
|
||||||
Image(systemName: isNewPasswordVisible ? "eye.slash.fill" : "eye.fill")
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.newPassword) { _, _ in
|
|
||||||
viewModel.clearError()
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("New Password")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm Password Input
|
|
||||||
Section {
|
|
||||||
HStack {
|
|
||||||
if isConfirmPasswordVisible {
|
|
||||||
TextField("Re-enter new password", text: $viewModel.confirmPassword)
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.focused($focusedField, equals: .confirmPassword)
|
|
||||||
.submitLabel(.go)
|
|
||||||
.onSubmit {
|
|
||||||
viewModel.resetPassword()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
SecureField("Re-enter new password", text: $viewModel.confirmPassword)
|
|
||||||
.focused($focusedField, equals: .confirmPassword)
|
|
||||||
.submitLabel(.go)
|
|
||||||
.onSubmit {
|
|
||||||
viewModel.resetPassword()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
isConfirmPasswordVisible.toggle()
|
|
||||||
}) {
|
|
||||||
Image(systemName: isConfirmPasswordVisible ? "eye.slash.fill" : "eye.fill")
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.confirmPassword) { _, _ in
|
|
||||||
viewModel.clearError()
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Confirm Password")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error/Success Messages
|
|
||||||
if let errorMessage = viewModel.errorMessage {
|
|
||||||
Section {
|
|
||||||
Label {
|
|
||||||
Text(errorMessage)
|
|
||||||
.foregroundColor(Color.appError)
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundColor(Color.appError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let successMessage = viewModel.successMessage {
|
|
||||||
Section {
|
|
||||||
Label {
|
|
||||||
Text(successMessage)
|
|
||||||
.foregroundColor(Color.appPrimary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(Color.appPrimary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset Password Button
|
|
||||||
Section {
|
|
||||||
Button(action: {
|
|
||||||
viewModel.resetPassword()
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
if viewModel.isLoading || viewModel.currentStep == .loggingIn {
|
|
||||||
ProgressView()
|
|
||||||
.padding(.trailing, 8)
|
|
||||||
Text(viewModel.currentStep == .loggingIn ? "Logging in..." : "Resetting...")
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
} else {
|
|
||||||
Label("Reset Password", systemImage: "lock.shield.fill")
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(!isFormValid || viewModel.isLoading || viewModel.currentStep == .loggingIn)
|
|
||||||
|
|
||||||
// Return to Login Button (shown only if auto-login fails)
|
|
||||||
if viewModel.currentStep == .success {
|
|
||||||
Button(action: {
|
|
||||||
viewModel.reset()
|
|
||||||
onSuccess()
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Text("Return to Login")
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.navigationTitle("Reset Password")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.navigationBarBackButtonHidden(true)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
|
||||||
// Only show back button if not from deep link and not logging in
|
|
||||||
if (viewModel.resetToken == nil || viewModel.currentStep != .resetPassword) && viewModel.currentStep != .loggingIn {
|
|
||||||
Button(action: {
|
|
||||||
if viewModel.currentStep == .success {
|
|
||||||
viewModel.reset()
|
|
||||||
onSuccess()
|
|
||||||
} else {
|
|
||||||
viewModel.moveToPreviousStep()
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: viewModel.currentStep == .success ? "xmark" : "chevron.left")
|
|
||||||
.font(.system(size: 16))
|
|
||||||
Text(viewModel.currentStep == .success ? "Close" : "Back")
|
|
||||||
.font(.subheadline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
focusedField = .newPassword
|
|
||||||
}
|
|
||||||
.handleErrors(
|
|
||||||
error: viewModel.errorMessage,
|
|
||||||
onRetry: { viewModel.resetPassword() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Computed Properties
|
// Computed Properties
|
||||||
private var hasLetter: Bool {
|
private var hasLetter: Bool {
|
||||||
viewModel.newPassword.range(of: "[A-Za-z]", options: .regularExpression) != nil
|
viewModel.newPassword.range(of: "[A-Za-z]", options: .regularExpression) != nil
|
||||||
@@ -271,6 +33,348 @@ struct ResetPasswordView: View {
|
|||||||
hasNumber &&
|
hasNumber &&
|
||||||
passwordsMatch
|
passwordsMatch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
ZStack {
|
||||||
|
WarmGradientBackground()
|
||||||
|
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(spacing: OrganicSpacing.spacious) {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: OrganicSpacing.comfortable)
|
||||||
|
|
||||||
|
// Hero Section
|
||||||
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(0.15),
|
||||||
|
Color.appPrimary.opacity(0.05),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 60
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
|
Image(systemName: "lock.rotation")
|
||||||
|
.font(.system(size: 48, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("Set New Password")
|
||||||
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
|
Text("Create a strong password to secure your account")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form Card
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Password Requirements
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("PASSWORD REQUIREMENTS")
|
||||||
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.tracking(1.2)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
RequirementRow(
|
||||||
|
isMet: viewModel.newPassword.count >= 8,
|
||||||
|
text: "At least 8 characters"
|
||||||
|
)
|
||||||
|
RequirementRow(
|
||||||
|
isMet: hasLetter,
|
||||||
|
text: "Contains letters"
|
||||||
|
)
|
||||||
|
RequirementRow(
|
||||||
|
isMet: hasNumber,
|
||||||
|
text: "Contains numbers"
|
||||||
|
)
|
||||||
|
RequirementRow(
|
||||||
|
isMet: passwordsMatch,
|
||||||
|
text: "Passwords match"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
// New Password Field
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("NEW PASSWORD")
|
||||||
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.tracking(1.2)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if isNewPasswordVisible {
|
||||||
|
TextField("Enter new password", text: $viewModel.newPassword)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
} else {
|
||||||
|
SecureField("Enter new password", text: $viewModel.newPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.focused($focusedField, equals: .newPassword)
|
||||||
|
.submitLabel(.next)
|
||||||
|
.onSubmit { focusedField = .confirmPassword }
|
||||||
|
.onChange(of: viewModel.newPassword) { _, _ in
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: { isNewPasswordVisible.toggle() }) {
|
||||||
|
Image(systemName: isNewPasswordVisible ? "eye.slash.fill" : "eye.fill")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.stroke(focusedField == .newPassword ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm Password Field
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("CONFIRM PASSWORD")
|
||||||
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.tracking(1.2)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if isConfirmPasswordVisible {
|
||||||
|
TextField("Re-enter new password", text: $viewModel.confirmPassword)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
} else {
|
||||||
|
SecureField("Re-enter new password", text: $viewModel.confirmPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.focused($focusedField, equals: .confirmPassword)
|
||||||
|
.submitLabel(.go)
|
||||||
|
.onSubmit { viewModel.resetPassword() }
|
||||||
|
.onChange(of: viewModel.confirmPassword) { _, _ in
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: { isConfirmPasswordVisible.toggle() }) {
|
||||||
|
Image(systemName: isConfirmPasswordVisible ? "eye.slash.fill" : "eye.fill")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.stroke(focusedField == .confirmPassword ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Message
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appError.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success Message
|
||||||
|
if let successMessage = viewModel.successMessage {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
Text(successMessage)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appPrimary.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset Password Button
|
||||||
|
Button(action: {
|
||||||
|
viewModel.resetPassword()
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if viewModel.isLoading || viewModel.currentStep == .loggingIn {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "lock.shield.fill")
|
||||||
|
}
|
||||||
|
Text(viewModel.currentStep == .loggingIn ? "Logging in..." : (viewModel.isLoading ? "Resetting..." : "Reset Password"))
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.background(
|
||||||
|
isFormValid && !viewModel.isLoading && viewModel.currentStep != .loggingIn
|
||||||
|
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
|
: AnyShapeStyle(Color.appTextSecondary)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.shadow(
|
||||||
|
color: isFormValid && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
|
||||||
|
radius: 10,
|
||||||
|
y: 5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.disabled(!isFormValid || viewModel.isLoading || viewModel.currentStep == .loggingIn)
|
||||||
|
|
||||||
|
// Return to Login Button
|
||||||
|
if viewModel.currentStep == .success {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.reset()
|
||||||
|
onSuccess()
|
||||||
|
}) {
|
||||||
|
Text("Return to Login")
|
||||||
|
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(OrganicSpacing.cozy)
|
||||||
|
.background(OrganicResetCardBackground())
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
|
||||||
|
.naturalShadow(.pronounced)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
if (viewModel.resetToken == nil || viewModel.currentStep != .resetPassword) && viewModel.currentStep != .loggingIn {
|
||||||
|
Button(action: {
|
||||||
|
if viewModel.currentStep == .success {
|
||||||
|
viewModel.reset()
|
||||||
|
onSuccess()
|
||||||
|
} else {
|
||||||
|
viewModel.moveToPreviousStep()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: viewModel.currentStep == .success ? "xmark" : "chevron.left")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
Text(viewModel.currentStep == .success ? "Close" : "Back")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
}
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
focusedField = .newPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Requirement Row
|
||||||
|
|
||||||
|
private struct RequirementRow: View {
|
||||||
|
let isMet: Bool
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: isMet ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(isMet ? Color.appPrimary : Color.appTextSecondary)
|
||||||
|
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundColor(isMet ? Color.appTextPrimary : Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Background
|
||||||
|
|
||||||
|
private struct OrganicResetCardBackground: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
|
||||||
|
GeometryReader { geo in
|
||||||
|
OrganicBlobShape(variation: 2)
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
|
||||||
|
Color.appPrimary.opacity(0.01)
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.5
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.4)
|
||||||
|
.offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.55)
|
||||||
|
.blur(radius: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
GrainTexture(opacity: 0.015)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@@ -7,149 +7,205 @@ struct VerifyResetCodeView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
Form {
|
ZStack {
|
||||||
// Header Section
|
WarmGradientBackground()
|
||||||
Section {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Image(systemName: "envelope.badge.fill")
|
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundStyle(Color.appPrimary.gradient)
|
|
||||||
.padding(.vertical)
|
|
||||||
|
|
||||||
Text("Check Your Email")
|
ScrollView(showsIndicators: false) {
|
||||||
.font(.title2)
|
VStack(spacing: OrganicSpacing.spacious) {
|
||||||
.fontWeight(.bold)
|
Spacer()
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.frame(height: OrganicSpacing.comfortable)
|
||||||
|
|
||||||
Text("We sent a 6-digit code to")
|
// Hero Section
|
||||||
.font(.subheadline)
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
.foregroundColor(Color.appTextSecondary)
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(0.15),
|
||||||
|
Color.appPrimary.opacity(0.05),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 60
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
Text(viewModel.email)
|
Image(systemName: "envelope.badge.fill")
|
||||||
.font(.subheadline)
|
.font(.system(size: 48, weight: .medium))
|
||||||
.fontWeight(.semibold)
|
.foregroundColor(Color.appPrimary)
|
||||||
.foregroundColor(Color.appTextPrimary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical)
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
|
|
||||||
// Info Section
|
|
||||||
Section {
|
|
||||||
Label {
|
|
||||||
Text("Code expires in 15 minutes")
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "clock.fill")
|
|
||||||
.foregroundColor(Color.appAccent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
|
|
||||||
// Code Input Section
|
|
||||||
Section {
|
|
||||||
TextField("000000", text: $viewModel.code)
|
|
||||||
.font(.system(size: 32, weight: .semibold, design: .rounded))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.keyboardType(.numberPad)
|
|
||||||
.focused($isCodeFocused)
|
|
||||||
.keyboardDismissToolbar()
|
|
||||||
.onChange(of: viewModel.code) { _, newValue in
|
|
||||||
// Limit to 6 digits
|
|
||||||
if newValue.count > 6 {
|
|
||||||
viewModel.code = String(newValue.prefix(6))
|
|
||||||
}
|
}
|
||||||
// Only allow numbers
|
|
||||||
viewModel.code = newValue.filter { $0.isNumber }
|
|
||||||
viewModel.clearError()
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Verification Code")
|
|
||||||
} footer: {
|
|
||||||
Text("Enter the 6-digit code from your email")
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
|
|
||||||
// Error/Success Messages
|
VStack(spacing: 8) {
|
||||||
if let errorMessage = viewModel.errorMessage {
|
Text("Check Your Email")
|
||||||
Section {
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
Label {
|
.foregroundColor(Color.appTextPrimary)
|
||||||
Text(errorMessage)
|
|
||||||
.foregroundColor(Color.appError)
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundColor(Color.appError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let successMessage = viewModel.successMessage {
|
Text("We sent a 6-digit code to")
|
||||||
Section {
|
.font(.system(size: 15, weight: .medium))
|
||||||
Label {
|
.foregroundColor(Color.appTextSecondary)
|
||||||
Text(successMessage)
|
|
||||||
.foregroundColor(Color.appPrimary)
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(Color.appPrimary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify Button
|
Text(viewModel.email)
|
||||||
Section {
|
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||||
Button(action: {
|
.foregroundColor(Color.appPrimary)
|
||||||
viewModel.verifyResetCode()
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
if viewModel.isLoading {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
Label("Verify Code", systemImage: "checkmark.shield.fill")
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
}
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
|
|
||||||
// Help Section
|
|
||||||
Section {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Text("Didn't receive the code?")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
// Clear code and go back to request new one
|
|
||||||
viewModel.code = ""
|
|
||||||
viewModel.clearError()
|
|
||||||
viewModel.currentStep = .requestCode
|
|
||||||
}) {
|
|
||||||
Text("Send New Code")
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Check your spam folder if you don't see it")
|
// Form Card
|
||||||
.font(.caption)
|
VStack(spacing: 20) {
|
||||||
.foregroundColor(Color.appTextSecondary)
|
// Timer Info
|
||||||
.multilineTextAlignment(.center)
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appAccent.opacity(0.1))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
Image(systemName: "clock.fill")
|
||||||
|
.font(.system(size: 18, weight: .medium))
|
||||||
|
.foregroundColor(Color.appAccent)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Code expires in 15 minutes")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appAccent.opacity(0.08))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
|
||||||
|
// Code Input
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("VERIFICATION CODE")
|
||||||
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.tracking(1.2)
|
||||||
|
|
||||||
|
TextField("000000", text: $viewModel.code)
|
||||||
|
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.focused($isCodeFocused)
|
||||||
|
.keyboardDismissToolbar()
|
||||||
|
.padding(20)
|
||||||
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.stroke(isCodeFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
||||||
|
)
|
||||||
|
.onChange(of: viewModel.code) { _, newValue in
|
||||||
|
if newValue.count > 6 {
|
||||||
|
viewModel.code = String(newValue.prefix(6))
|
||||||
|
}
|
||||||
|
viewModel.code = newValue.filter { $0.isNumber }
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Enter the 6-digit code from your email")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Message
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appError.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success Message
|
||||||
|
if let successMessage = viewModel.successMessage {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
Text(successMessage)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appPrimary.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Button
|
||||||
|
Button(action: {
|
||||||
|
viewModel.verifyResetCode()
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "checkmark.shield.fill")
|
||||||
|
}
|
||||||
|
Text(viewModel.isLoading ? "Verifying..." : "Verify Code")
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.background(
|
||||||
|
viewModel.code.count == 6 && !viewModel.isLoading
|
||||||
|
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
|
: AnyShapeStyle(Color.appTextSecondary)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.shadow(
|
||||||
|
color: viewModel.code.count == 6 && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
|
||||||
|
radius: 10,
|
||||||
|
y: 5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
|
||||||
|
|
||||||
|
OrganicDivider()
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
|
// Help Section
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("Didn't receive the code?")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
viewModel.code = ""
|
||||||
|
viewModel.clearError()
|
||||||
|
viewModel.currentStep = .requestCode
|
||||||
|
}) {
|
||||||
|
Text("Send New Code")
|
||||||
|
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Check your spam folder if you don't see it")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(OrganicSpacing.cozy)
|
||||||
|
.background(OrganicVerifyCardBackground())
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
|
||||||
|
.naturalShadow(.pronounced)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.navigationTitle("Verify Code")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -157,22 +213,51 @@ struct VerifyResetCodeView: View {
|
|||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.moveToPreviousStep()
|
viewModel.moveToPreviousStep()
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.font(.system(size: 16))
|
.font(.system(size: 14, weight: .semibold))
|
||||||
Text("Back")
|
Text("Back")
|
||||||
.font(.subheadline)
|
.font(.system(size: 15, weight: .medium))
|
||||||
}
|
}
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
isCodeFocused = true
|
isCodeFocused = true
|
||||||
}
|
}
|
||||||
.handleErrors(
|
}
|
||||||
error: viewModel.errorMessage,
|
}
|
||||||
onRetry: { viewModel.verifyResetCode() }
|
}
|
||||||
)
|
|
||||||
|
// MARK: - Background
|
||||||
|
|
||||||
|
private struct OrganicVerifyCardBackground: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
|
||||||
|
GeometryReader { geo in
|
||||||
|
OrganicBlobShape(variation: 1)
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
|
||||||
|
Color.appPrimary.opacity(0.01)
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.5
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.4)
|
||||||
|
.offset(x: geo.size.width * 0.45, y: geo.size.height * 0.5)
|
||||||
|
.blur(radius: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
GrainTexture(opacity: 0.015)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,128 +6,213 @@ struct RegisterView: View {
|
|||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
@State private var showVerifyEmail = false
|
@State private var showVerifyEmail = false
|
||||||
|
@State private var isPasswordVisible = false
|
||||||
|
@State private var isConfirmPasswordVisible = false
|
||||||
|
|
||||||
enum Field {
|
enum Field {
|
||||||
case username, email, password, confirmPassword
|
case username, email, password, confirmPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isFormValid: Bool {
|
||||||
|
!viewModel.username.isEmpty &&
|
||||||
|
!viewModel.email.isEmpty &&
|
||||||
|
!viewModel.password.isEmpty &&
|
||||||
|
!viewModel.confirmPassword.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
Form {
|
ZStack {
|
||||||
Section {
|
WarmGradientBackground()
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "person.badge.plus")
|
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundStyle(Color.appPrimary.gradient)
|
|
||||||
|
|
||||||
Text(L10n.Auth.joinCasera)
|
ScrollView(showsIndicators: false) {
|
||||||
.font(.largeTitle)
|
VStack(spacing: OrganicSpacing.spacious) {
|
||||||
.fontWeight(.bold)
|
Spacer()
|
||||||
|
.frame(height: OrganicSpacing.comfortable)
|
||||||
|
|
||||||
Text(L10n.Auth.startManaging)
|
// Hero Section
|
||||||
.font(.subheadline)
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
.foregroundColor(Color.appTextSecondary)
|
ZStack {
|
||||||
}
|
Circle()
|
||||||
.frame(maxWidth: .infinity)
|
.fill(
|
||||||
.padding(.vertical)
|
RadialGradient(
|
||||||
}
|
colors: [
|
||||||
.listRowBackground(Color.clear)
|
Color.appPrimary.opacity(0.15),
|
||||||
|
Color.appPrimary.opacity(0.05),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 60
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
Section {
|
Image(systemName: "person.badge.plus")
|
||||||
TextField(L10n.Auth.registerUsername, text: $viewModel.username)
|
.font(.system(size: 48, weight: .medium))
|
||||||
.textInputAutocapitalization(.never)
|
.foregroundColor(Color.appPrimary)
|
||||||
.autocorrectionDisabled()
|
}
|
||||||
.textContentType(.username)
|
|
||||||
.focused($focusedField, equals: .username)
|
VStack(spacing: 8) {
|
||||||
.submitLabel(.next)
|
Text(L10n.Auth.joinCasera)
|
||||||
.onSubmit {
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
focusedField = .email
|
.foregroundColor(Color.appTextPrimary)
|
||||||
}
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerUsernameField)
|
Text(L10n.Auth.startManaging)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
TextField(L10n.Auth.registerEmail, text: $viewModel.email)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.keyboardType(.emailAddress)
|
|
||||||
.textContentType(.emailAddress)
|
|
||||||
.focused($focusedField, equals: .email)
|
|
||||||
.submitLabel(.next)
|
|
||||||
.onSubmit {
|
|
||||||
focusedField = .password
|
|
||||||
}
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerEmailField)
|
|
||||||
} header: {
|
|
||||||
Text(L10n.Auth.accountInfo)
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
|
|
||||||
Section {
|
|
||||||
// Using .newPassword enables iOS Strong Password generation
|
|
||||||
// iOS will automatically offer to save to iCloud Keychain after successful registration
|
|
||||||
SecureField(L10n.Auth.registerPassword, text: $viewModel.password)
|
|
||||||
.textContentType(.newPassword)
|
|
||||||
.focused($focusedField, equals: .password)
|
|
||||||
.submitLabel(.next)
|
|
||||||
.onSubmit {
|
|
||||||
focusedField = .confirmPassword
|
|
||||||
}
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerPasswordField)
|
|
||||||
|
|
||||||
SecureField(L10n.Auth.registerConfirmPassword, text: $viewModel.confirmPassword)
|
|
||||||
.textContentType(.newPassword)
|
|
||||||
.focused($focusedField, equals: .confirmPassword)
|
|
||||||
.submitLabel(.go)
|
|
||||||
.onSubmit {
|
|
||||||
viewModel.register()
|
|
||||||
}
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerConfirmPasswordField)
|
|
||||||
} header: {
|
|
||||||
Text(L10n.Auth.security)
|
|
||||||
} footer: {
|
|
||||||
Text(L10n.Auth.passwordSuggestion)
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
|
|
||||||
if let errorMessage = viewModel.errorMessage {
|
|
||||||
Section {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundColor(Color.appError)
|
|
||||||
Text(errorMessage)
|
|
||||||
.foregroundColor(Color.appError)
|
|
||||||
.font(.subheadline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Button(action: viewModel.register) {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
if viewModel.isLoading {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
Text(L10n.Auth.registerButton)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
}
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registration Card
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Username Field
|
||||||
|
OrganicTextField(
|
||||||
|
label: L10n.Auth.accountInfo,
|
||||||
|
placeholder: L10n.Auth.registerUsername,
|
||||||
|
text: $viewModel.username,
|
||||||
|
icon: "person.fill",
|
||||||
|
isFocused: focusedField == .username
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .username)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textContentType(.username)
|
||||||
|
.submitLabel(.next)
|
||||||
|
.onSubmit { focusedField = .email }
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerUsernameField)
|
||||||
|
|
||||||
|
// Email Field
|
||||||
|
OrganicTextField(
|
||||||
|
label: nil,
|
||||||
|
placeholder: L10n.Auth.registerEmail,
|
||||||
|
text: $viewModel.email,
|
||||||
|
icon: "envelope.fill",
|
||||||
|
isFocused: focusedField == .email
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .email)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.textContentType(.emailAddress)
|
||||||
|
.submitLabel(.next)
|
||||||
|
.onSubmit { focusedField = .password }
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerEmailField)
|
||||||
|
|
||||||
|
OrganicDivider()
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
|
// Password Field
|
||||||
|
OrganicSecureField(
|
||||||
|
label: L10n.Auth.security,
|
||||||
|
placeholder: L10n.Auth.registerPassword,
|
||||||
|
text: $viewModel.password,
|
||||||
|
isVisible: $isPasswordVisible,
|
||||||
|
isFocused: focusedField == .password
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .password)
|
||||||
|
.textContentType(.newPassword)
|
||||||
|
.submitLabel(.next)
|
||||||
|
.onSubmit { focusedField = .confirmPassword }
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerPasswordField)
|
||||||
|
|
||||||
|
// Confirm Password Field
|
||||||
|
OrganicSecureField(
|
||||||
|
label: nil,
|
||||||
|
placeholder: L10n.Auth.registerConfirmPassword,
|
||||||
|
text: $viewModel.confirmPassword,
|
||||||
|
isVisible: $isConfirmPasswordVisible,
|
||||||
|
isFocused: focusedField == .confirmPassword
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .confirmPassword)
|
||||||
|
.textContentType(.newPassword)
|
||||||
|
.submitLabel(.go)
|
||||||
|
.onSubmit { viewModel.register() }
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerConfirmPasswordField)
|
||||||
|
|
||||||
|
Text(L10n.Auth.passwordSuggestion)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// Error Message
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appError.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register Button
|
||||||
|
Button(action: viewModel.register) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
}
|
||||||
|
Text(viewModel.isLoading ? L10n.Auth.creatingAccount : L10n.Auth.registerButton)
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.background(
|
||||||
|
isFormValid && !viewModel.isLoading
|
||||||
|
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
|
: AnyShapeStyle(Color.appTextSecondary)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.shadow(
|
||||||
|
color: isFormValid && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
|
||||||
|
radius: 10,
|
||||||
|
y: 5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.disabled(!isFormValid || viewModel.isLoading)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerButton)
|
||||||
|
|
||||||
|
// Login Link
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(L10n.Auth.alreadyHaveAccount)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
|
||||||
|
Button(L10n.Auth.signIn) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.padding(OrganicSpacing.cozy)
|
||||||
|
.background(OrganicFormBackground())
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
|
||||||
|
.naturalShadow(.pronounced)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.disabled(viewModel.isLoading)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerButton)
|
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.navigationTitle(L10n.Auth.registerTitle)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
Button(L10n.Common.cancel) {
|
Button(action: { dismiss() }) {
|
||||||
dismiss()
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.appBackgroundSecondary.opacity(0.8))
|
||||||
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerCancelButton)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerCancelButton)
|
||||||
}
|
}
|
||||||
@@ -135,23 +220,16 @@ struct RegisterView: View {
|
|||||||
.fullScreenCover(isPresented: $viewModel.isRegistered) {
|
.fullScreenCover(isPresented: $viewModel.isRegistered) {
|
||||||
VerifyEmailView(
|
VerifyEmailView(
|
||||||
onVerifySuccess: {
|
onVerifySuccess: {
|
||||||
// User has verified their email - mark as verified
|
|
||||||
// This will update RootView to show the main app
|
|
||||||
AuthenticationManager.shared.markVerified()
|
AuthenticationManager.shared.markVerified()
|
||||||
showVerifyEmail = false
|
showVerifyEmail = false
|
||||||
dismiss()
|
dismiss()
|
||||||
},
|
},
|
||||||
onLogout: {
|
onLogout: {
|
||||||
// Logout and return to login screen
|
|
||||||
AuthenticationManager.shared.logout()
|
AuthenticationManager.shared.logout()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.handleErrors(
|
|
||||||
error: viewModel.errorMessage,
|
|
||||||
onRetry: { viewModel.register() }
|
|
||||||
)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
PostHogAnalytics.shared.screen(AnalyticsEvents.registrationScreenShown)
|
PostHogAnalytics.shared.screen(AnalyticsEvents.registrationScreenShown)
|
||||||
}
|
}
|
||||||
@@ -159,6 +237,136 @@ struct RegisterView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Organic Text Field
|
||||||
|
|
||||||
|
private struct OrganicTextField: View {
|
||||||
|
let label: String?
|
||||||
|
let placeholder: String
|
||||||
|
@Binding var text: String
|
||||||
|
let icon: String
|
||||||
|
var isFocused: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if let label = label {
|
||||||
|
Text(label.uppercased())
|
||||||
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.tracking(1.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField(placeholder, text: $text)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.stroke(isFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
||||||
|
)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: isFocused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Organic Secure Field
|
||||||
|
|
||||||
|
private struct OrganicSecureField: View {
|
||||||
|
let label: String?
|
||||||
|
let placeholder: String
|
||||||
|
@Binding var text: String
|
||||||
|
@Binding var isVisible: Bool
|
||||||
|
var isFocused: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if let label = label {
|
||||||
|
Text(label.uppercased())
|
||||||
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.tracking(1.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if isVisible {
|
||||||
|
TextField(placeholder, text: $text)
|
||||||
|
} else {
|
||||||
|
SecureField(placeholder, text: $text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
|
||||||
|
Button(action: { isVisible.toggle() }) {
|
||||||
|
Image(systemName: isVisible ? "eye.slash.fill" : "eye.fill")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.stroke(isFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
||||||
|
)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: isFocused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Organic Form Background
|
||||||
|
|
||||||
|
private struct OrganicFormBackground: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
|
||||||
|
GeometryReader { geo in
|
||||||
|
OrganicBlobShape(variation: 1)
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
|
||||||
|
Color.appPrimary.opacity(0.01)
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.5
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.4)
|
||||||
|
.offset(x: geo.size.width * 0.5, y: -geo.size.height * 0.05)
|
||||||
|
.blur(radius: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
GrainTexture(opacity: 0.015)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
RegisterView()
|
RegisterView()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,70 +7,170 @@ struct JoinResidenceView: View {
|
|||||||
let onJoined: () -> Void
|
let onJoined: () -> Void
|
||||||
|
|
||||||
@State private var shareCode: String = ""
|
@State private var shareCode: String = ""
|
||||||
|
@FocusState private var isCodeFocused: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
Form {
|
ZStack {
|
||||||
Section {
|
WarmGradientBackground()
|
||||||
TextField(L10n.Residences.shareCode, text: $shareCode)
|
|
||||||
.textInputAutocapitalization(.characters)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.onChange(of: shareCode) { newValue in
|
|
||||||
// Limit to 6 characters and uppercase
|
|
||||||
if newValue.count > 6 {
|
|
||||||
shareCode = String(newValue.prefix(6))
|
|
||||||
}
|
|
||||||
shareCode = shareCode.uppercased()
|
|
||||||
viewModel.clearError()
|
|
||||||
}
|
|
||||||
.disabled(viewModel.isLoading)
|
|
||||||
} header: {
|
|
||||||
Text(L10n.Residences.enterShareCode)
|
|
||||||
} footer: {
|
|
||||||
Text(L10n.Residences.shareCodeFooter)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
|
|
||||||
if let error = viewModel.errorMessage {
|
ScrollView(showsIndicators: false) {
|
||||||
Section {
|
VStack(spacing: OrganicSpacing.spacious) {
|
||||||
Text(error)
|
Spacer()
|
||||||
.foregroundColor(Color.appError)
|
.frame(height: OrganicSpacing.comfortable)
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
// Hero Section
|
||||||
Button(action: joinResidence) {
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
HStack {
|
ZStack {
|
||||||
Spacer()
|
Circle()
|
||||||
if viewModel.isLoading {
|
.fill(
|
||||||
ProgressView()
|
RadialGradient(
|
||||||
.progressViewStyle(CircularProgressViewStyle())
|
colors: [
|
||||||
} else {
|
Color.appPrimary.opacity(0.15),
|
||||||
Text(L10n.Residences.joinButton)
|
Color.appPrimary.opacity(0.05),
|
||||||
.fontWeight(.semibold)
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 60
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
|
Image(systemName: "person.badge.plus")
|
||||||
|
.font(.system(size: 48, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text(L10n.Residences.joinTitle)
|
||||||
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
|
Text(L10n.Residences.enterShareCode)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Form Card
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Share Code Input
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(L10n.Residences.shareCode.uppercased())
|
||||||
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.tracking(1.2)
|
||||||
|
|
||||||
|
TextField("ABC123", text: $shareCode)
|
||||||
|
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.textInputAutocapitalization(.characters)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($isCodeFocused)
|
||||||
|
.disabled(viewModel.isLoading)
|
||||||
|
.padding(20)
|
||||||
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.stroke(isCodeFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
||||||
|
)
|
||||||
|
.onChange(of: shareCode) { newValue in
|
||||||
|
if newValue.count > 6 {
|
||||||
|
shareCode = String(newValue.prefix(6))
|
||||||
|
}
|
||||||
|
shareCode = shareCode.uppercased()
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(L10n.Residences.shareCodeFooter)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Message
|
||||||
|
if let error = viewModel.errorMessage {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appError.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join Button
|
||||||
|
Button(action: joinResidence) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "person.badge.plus")
|
||||||
|
}
|
||||||
|
Text(viewModel.isLoading ? "Joining..." : L10n.Residences.joinButton)
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.background(
|
||||||
|
shareCode.count == 6 && !viewModel.isLoading
|
||||||
|
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
|
: AnyShapeStyle(Color.appTextSecondary)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.shadow(
|
||||||
|
color: shareCode.count == 6 && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
|
||||||
|
radius: 10,
|
||||||
|
y: 5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.disabled(shareCode.count != 6 || viewModel.isLoading)
|
||||||
|
|
||||||
|
// Cancel Button
|
||||||
|
Button(action: { dismiss() }) {
|
||||||
|
Text(L10n.Common.cancel)
|
||||||
|
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isLoading)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.padding(OrganicSpacing.cozy)
|
||||||
|
.background(OrganicJoinCardBackground())
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
|
||||||
|
.naturalShadow(.pronounced)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.disabled(shareCode.count != 6 || viewModel.isLoading)
|
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.navigationTitle(L10n.Residences.joinTitle)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
Button(L10n.Common.cancel) {
|
Button(action: { dismiss() }) {
|
||||||
dismiss()
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.appBackgroundSecondary.opacity(0.8))
|
||||||
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
.disabled(viewModel.isLoading)
|
.disabled(viewModel.isLoading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
isCodeFocused = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +185,38 @@ struct JoinResidenceView: View {
|
|||||||
onJoined()
|
onJoined()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
// Error is handled by ViewModel and displayed via viewModel.errorMessage
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Background
|
||||||
|
|
||||||
|
private struct OrganicJoinCardBackground: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
|
||||||
|
GeometryReader { geo in
|
||||||
|
OrganicBlobShape(variation: 1)
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
|
||||||
|
Color.appPrimary.opacity(0.01)
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.5
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.5)
|
||||||
|
.offset(x: geo.size.width * 0.4, y: geo.size.height * 0.4)
|
||||||
|
.blur(radius: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
GrainTexture(opacity: 0.015)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ struct ManageUsersView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.appBackgroundPrimary
|
WarmGradientBackground()
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -71,7 +70,6 @@ struct ManageUsersView: View {
|
|||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.navigationTitle(L10n.Residences.manageUsers)
|
.navigationTitle(L10n.Residences.manageUsers)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
@@ -60,172 +60,256 @@ struct ResidenceFormView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
Form {
|
ZStack {
|
||||||
Section {
|
WarmGradientBackground()
|
||||||
TextField(L10n.Residences.propertyName, text: $name)
|
|
||||||
.focused($focusedField, equals: .name)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.nameField)
|
|
||||||
|
|
||||||
if !nameError.isEmpty {
|
ScrollView(showsIndicators: false) {
|
||||||
Text(nameError)
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
.font(.caption)
|
// Property Details Section
|
||||||
.foregroundColor(Color.appError)
|
OrganicFormSection(title: L10n.Residences.propertyDetails, icon: "house.fill") {
|
||||||
}
|
VStack(spacing: 16) {
|
||||||
|
OrganicFormTextField(
|
||||||
Picker(L10n.Residences.propertyType, selection: $selectedPropertyType) {
|
label: L10n.Residences.propertyName,
|
||||||
Text(L10n.Residences.selectType).tag(nil as ResidenceType?)
|
placeholder: "My Home",
|
||||||
ForEach(residenceTypes, id: \.id) { type in
|
text: $name,
|
||||||
Text(type.name).tag(type as ResidenceType?)
|
error: nameError.isEmpty ? nil : nameError
|
||||||
}
|
|
||||||
}
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.propertyTypePicker)
|
|
||||||
} header: {
|
|
||||||
Text(L10n.Residences.propertyDetails)
|
|
||||||
} footer: {
|
|
||||||
Text(L10n.Residences.requiredName)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(Color.appError)
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
|
|
||||||
Section {
|
|
||||||
TextField(L10n.Residences.streetAddress, text: $streetAddress)
|
|
||||||
.focused($focusedField, equals: .streetAddress)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.streetAddressField)
|
|
||||||
|
|
||||||
TextField(L10n.Residences.apartmentUnit, text: $apartmentUnit)
|
|
||||||
.focused($focusedField, equals: .apartmentUnit)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.apartmentUnitField)
|
|
||||||
|
|
||||||
TextField(L10n.Residences.city, text: $city)
|
|
||||||
.focused($focusedField, equals: .city)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.cityField)
|
|
||||||
|
|
||||||
TextField(L10n.Residences.stateProvince, text: $stateProvince)
|
|
||||||
.focused($focusedField, equals: .stateProvince)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.stateProvinceField)
|
|
||||||
|
|
||||||
TextField(L10n.Residences.postalCode, text: $postalCode)
|
|
||||||
.focused($focusedField, equals: .postalCode)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.postalCodeField)
|
|
||||||
|
|
||||||
TextField(L10n.Residences.country, text: $country)
|
|
||||||
.focused($focusedField, equals: .country)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.countryField)
|
|
||||||
} header: {
|
|
||||||
Text(L10n.Residences.address)
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
|
|
||||||
Section(header: Text(L10n.Residences.propertyFeatures)) {
|
|
||||||
HStack {
|
|
||||||
Text(L10n.Residences.bedrooms)
|
|
||||||
Spacer()
|
|
||||||
TextField("0", text: $bedrooms)
|
|
||||||
.keyboardType(.numberPad)
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.frame(width: 60)
|
|
||||||
.focused($focusedField, equals: .bedrooms)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bedroomsField)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text(L10n.Residences.bathrooms)
|
|
||||||
Spacer()
|
|
||||||
TextField("0.0", text: $bathrooms)
|
|
||||||
.keyboardType(.decimalPad)
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.frame(width: 60)
|
|
||||||
.focused($focusedField, equals: .bathrooms)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bathroomsField)
|
|
||||||
}
|
|
||||||
|
|
||||||
TextField(L10n.Residences.squareFootage, text: $squareFootage)
|
|
||||||
.keyboardType(.numberPad)
|
|
||||||
.focused($focusedField, equals: .squareFootage)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.squareFootageField)
|
|
||||||
|
|
||||||
TextField(L10n.Residences.lotSize, text: $lotSize)
|
|
||||||
.keyboardType(.decimalPad)
|
|
||||||
.focused($focusedField, equals: .lotSize)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.lotSizeField)
|
|
||||||
|
|
||||||
TextField(L10n.Residences.yearBuilt, text: $yearBuilt)
|
|
||||||
.keyboardType(.numberPad)
|
|
||||||
.focused($focusedField, equals: .yearBuilt)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.yearBuiltField)
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
.keyboardDismissToolbar()
|
|
||||||
|
|
||||||
Section(header: Text(L10n.Residences.additionalDetails)) {
|
|
||||||
TextField(L10n.Residences.description, text: $description, axis: .vertical)
|
|
||||||
.lineLimit(3...6)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.descriptionField)
|
|
||||||
.keyboardDismissToolbar()
|
|
||||||
|
|
||||||
Toggle(L10n.Residences.primaryResidence, isOn: $isPrimary)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle)
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
|
|
||||||
// Users section (edit mode only, owner only)
|
|
||||||
if isEditMode && isCurrentUserOwner {
|
|
||||||
Section {
|
|
||||||
if isLoadingUsers {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
ProgressView()
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
} else if users.isEmpty {
|
|
||||||
Text("No shared users")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
} else {
|
|
||||||
ForEach(users, id: \.id) { user in
|
|
||||||
UserRow(
|
|
||||||
user: user,
|
|
||||||
isOwner: user.id == existingResidence?.ownerId,
|
|
||||||
onRemove: {
|
|
||||||
userToRemove = user
|
|
||||||
showRemoveUserConfirmation = true
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
.focused($focusedField, equals: .name)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.nameField)
|
||||||
|
|
||||||
|
OrganicFormPicker(
|
||||||
|
label: L10n.Residences.propertyType,
|
||||||
|
selection: $selectedPropertyType,
|
||||||
|
options: residenceTypes,
|
||||||
|
optionLabel: { $0.name },
|
||||||
|
placeholder: L10n.Residences.selectType
|
||||||
|
)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.propertyTypePicker)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
.padding(.top, 8)
|
||||||
Text("Shared Users (\(users.count))")
|
|
||||||
} footer: {
|
|
||||||
Text("Users with access to this residence. Use the share button to invite others.")
|
|
||||||
}
|
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let errorMessage = viewModel.errorMessage {
|
// Address Section
|
||||||
Section {
|
OrganicFormSection(title: L10n.Residences.address, icon: "mappin.circle.fill") {
|
||||||
Text(errorMessage)
|
VStack(spacing: 16) {
|
||||||
.foregroundColor(Color.appError)
|
OrganicFormTextField(
|
||||||
.font(.caption)
|
label: L10n.Residences.streetAddress,
|
||||||
|
placeholder: "123 Main St",
|
||||||
|
text: $streetAddress
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .streetAddress)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.streetAddressField)
|
||||||
|
|
||||||
|
OrganicFormTextField(
|
||||||
|
label: L10n.Residences.apartmentUnit,
|
||||||
|
placeholder: "Apt 4B",
|
||||||
|
text: $apartmentUnit
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .apartmentUnit)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.apartmentUnitField)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
OrganicFormTextField(
|
||||||
|
label: L10n.Residences.city,
|
||||||
|
placeholder: "City",
|
||||||
|
text: $city
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .city)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.cityField)
|
||||||
|
|
||||||
|
OrganicFormTextField(
|
||||||
|
label: L10n.Residences.stateProvince,
|
||||||
|
placeholder: "State",
|
||||||
|
text: $stateProvince
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .stateProvince)
|
||||||
|
.frame(maxWidth: 120)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.stateProvinceField)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
OrganicFormTextField(
|
||||||
|
label: L10n.Residences.postalCode,
|
||||||
|
placeholder: "12345",
|
||||||
|
text: $postalCode
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .postalCode)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.postalCodeField)
|
||||||
|
|
||||||
|
OrganicFormTextField(
|
||||||
|
label: L10n.Residences.country,
|
||||||
|
placeholder: "USA",
|
||||||
|
text: $country
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .country)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.countryField)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property Features Section
|
||||||
|
OrganicFormSection(title: L10n.Residences.propertyFeatures, icon: "square.grid.2x2.fill") {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
OrganicFormTextField(
|
||||||
|
label: L10n.Residences.bedrooms,
|
||||||
|
placeholder: "0",
|
||||||
|
text: $bedrooms,
|
||||||
|
keyboardType: .numberPad
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .bedrooms)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bedroomsField)
|
||||||
|
|
||||||
|
OrganicFormTextField(
|
||||||
|
label: L10n.Residences.bathrooms,
|
||||||
|
placeholder: "0.0",
|
||||||
|
text: $bathrooms,
|
||||||
|
keyboardType: .decimalPad
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .bathrooms)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bathroomsField)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
OrganicFormTextField(
|
||||||
|
label: L10n.Residences.squareFootage,
|
||||||
|
placeholder: "sq ft",
|
||||||
|
text: $squareFootage,
|
||||||
|
keyboardType: .numberPad
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .squareFootage)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.squareFootageField)
|
||||||
|
|
||||||
|
OrganicFormTextField(
|
||||||
|
label: L10n.Residences.lotSize,
|
||||||
|
placeholder: "acres",
|
||||||
|
text: $lotSize,
|
||||||
|
keyboardType: .decimalPad
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .lotSize)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.lotSizeField)
|
||||||
|
}
|
||||||
|
|
||||||
|
OrganicFormTextField(
|
||||||
|
label: L10n.Residences.yearBuilt,
|
||||||
|
placeholder: "2020",
|
||||||
|
text: $yearBuilt,
|
||||||
|
keyboardType: .numberPad
|
||||||
|
)
|
||||||
|
.focused($focusedField, equals: .yearBuilt)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.yearBuiltField)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional Details Section
|
||||||
|
OrganicFormSection(title: L10n.Residences.additionalDetails, icon: "text.alignleft") {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
OrganicFormTextArea(
|
||||||
|
label: L10n.Residences.description,
|
||||||
|
placeholder: "Add notes about your property...",
|
||||||
|
text: $description
|
||||||
|
)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.descriptionField)
|
||||||
|
|
||||||
|
OrganicFormToggle(
|
||||||
|
label: L10n.Residences.primaryResidence,
|
||||||
|
isOn: $isPrimary,
|
||||||
|
icon: "star.fill"
|
||||||
|
)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users Section (edit mode only, owner only)
|
||||||
|
if isEditMode && isCurrentUserOwner {
|
||||||
|
OrganicFormSection(title: "Shared Users (\(users.count))", icon: "person.2.fill") {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
if isLoadingUsers {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
} else if users.isEmpty {
|
||||||
|
Text("No shared users")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
} else {
|
||||||
|
ForEach(users, id: \.id) { user in
|
||||||
|
OrganicUserRow(
|
||||||
|
user: user,
|
||||||
|
isOwner: user.id == existingResidence?.ownerId,
|
||||||
|
onRemove: {
|
||||||
|
userToRemove = user
|
||||||
|
showRemoveUserConfirmation = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Use the share button to invite others")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Message
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appError.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 40)
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.appBackgroundSecondary)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
|
.keyboardDismissToolbar()
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.navigationTitle(isEditMode ? L10n.Residences.editTitle : L10n.Residences.addTitle)
|
.navigationTitle(isEditMode ? L10n.Residences.editTitle : L10n.Residences.addTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
Button(L10n.Common.cancel) {
|
Button(action: { isPresented = false }) {
|
||||||
isPresented = false
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.appBackgroundSecondary.opacity(0.8))
|
||||||
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.formCancelButton)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.formCancelButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(L10n.Common.save) {
|
Button(action: submitForm) {
|
||||||
submitForm()
|
HStack(spacing: 6) {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: Color.appTextOnPrimary))
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
}
|
||||||
|
Text(L10n.Common.save)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(canSave ? Color.appTextOnPrimary : Color.appTextSecondary)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(canSave ? Color.appPrimary : Color.appTextSecondary.opacity(0.3))
|
||||||
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
.disabled(!canSave || viewModel.isLoading)
|
.disabled(!canSave || viewModel.isLoading)
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.saveButton)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.saveButton)
|
||||||
@@ -255,25 +339,17 @@ struct ResidenceFormView: View {
|
|||||||
Text("Are you sure you want to remove \(user.username) from this residence?")
|
Text("Are you sure you want to remove \(user.username) from this residence?")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.handleErrors(
|
|
||||||
error: viewModel.errorMessage,
|
|
||||||
onRetry: { submitForm() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadResidenceTypes() {
|
private func loadResidenceTypes() {
|
||||||
Task {
|
Task {
|
||||||
// Trigger residence types refresh if needed
|
|
||||||
// Residence types are now loaded from DataManagerObservable
|
|
||||||
// Just trigger a refresh if needed
|
|
||||||
_ = try? await APILayer.shared.getResidenceTypes(forceRefresh: false)
|
_ = try? await APILayer.shared.getResidenceTypes(forceRefresh: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func initializeForm() {
|
private func initializeForm() {
|
||||||
if let residence = existingResidence {
|
if let residence = existingResidence {
|
||||||
// Edit mode - populate fields from existing residence
|
|
||||||
name = residence.name
|
name = residence.name
|
||||||
streetAddress = residence.streetAddress ?? ""
|
streetAddress = residence.streetAddress ?? ""
|
||||||
apartmentUnit = residence.apartmentUnit ?? ""
|
apartmentUnit = residence.apartmentUnit ?? ""
|
||||||
@@ -289,12 +365,10 @@ struct ResidenceFormView: View {
|
|||||||
description = residence.description_ ?? ""
|
description = residence.description_ ?? ""
|
||||||
isPrimary = residence.isPrimary
|
isPrimary = residence.isPrimary
|
||||||
|
|
||||||
// Set the selected property type
|
|
||||||
if let propertyTypeId = residence.propertyTypeId {
|
if let propertyTypeId = residence.propertyTypeId {
|
||||||
selectedPropertyType = residenceTypes.first { $0.id == Int32(propertyTypeId) }
|
selectedPropertyType = residenceTypes.first { $0.id == Int32(propertyTypeId) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// In add mode, leave selectedPropertyType as nil to force user to select
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func validateForm() -> Bool {
|
private func validateForm() -> Bool {
|
||||||
@@ -313,7 +387,6 @@ struct ResidenceFormView: View {
|
|||||||
private func submitForm() {
|
private func submitForm() {
|
||||||
guard validateForm() else { return }
|
guard validateForm() else { return }
|
||||||
|
|
||||||
// Convert optional numeric fields to Kotlin types
|
|
||||||
let bedroomsValue: KotlinInt? = {
|
let bedroomsValue: KotlinInt? = {
|
||||||
guard !bedrooms.isEmpty, let value = Int32(bedrooms) else { return nil }
|
guard !bedrooms.isEmpty, let value = Int32(bedrooms) else { return nil }
|
||||||
return KotlinInt(int: value)
|
return KotlinInt(int: value)
|
||||||
@@ -335,7 +408,6 @@ struct ResidenceFormView: View {
|
|||||||
return KotlinInt(int: value)
|
return KotlinInt(int: value)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Convert propertyType to KotlinInt if it exists
|
|
||||||
let propertyTypeValue: KotlinInt? = {
|
let propertyTypeValue: KotlinInt? = {
|
||||||
guard let type = selectedPropertyType else { return nil }
|
guard let type = selectedPropertyType else { return nil }
|
||||||
return KotlinInt(int: Int32(type.id))
|
return KotlinInt(int: Int32(type.id))
|
||||||
@@ -362,7 +434,6 @@ struct ResidenceFormView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if let residence = existingResidence {
|
if let residence = existingResidence {
|
||||||
// Edit mode
|
|
||||||
viewModel.updateResidence(id: residence.id, request: request) { success in
|
viewModel.updateResidence(id: residence.id, request: request) { success in
|
||||||
if success {
|
if success {
|
||||||
onSuccess?()
|
onSuccess?()
|
||||||
@@ -370,10 +441,8 @@ struct ResidenceFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add mode
|
|
||||||
viewModel.createResidence(request: request) { success in
|
viewModel.createResidence(request: request) { success in
|
||||||
if success {
|
if success {
|
||||||
// Track residence created
|
|
||||||
PostHogAnalytics.shared.capture(AnalyticsEvents.residenceCreated, properties: [
|
PostHogAnalytics.shared.capture(AnalyticsEvents.residenceCreated, properties: [
|
||||||
"residence_type": selectedPropertyType?.name ?? "unknown"
|
"residence_type": selectedPropertyType?.name ?? "unknown"
|
||||||
])
|
])
|
||||||
@@ -397,7 +466,6 @@ struct ResidenceFormView: View {
|
|||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if let successResult = result as? ApiResultSuccess<NSArray>,
|
if let successResult = result as? ApiResultSuccess<NSArray>,
|
||||||
let responseData = successResult.data as? [ResidenceUserResponse] {
|
let responseData = successResult.data as? [ResidenceUserResponse] {
|
||||||
// Filter out the owner from the list
|
|
||||||
self.users = responseData.filter { $0.id != residence.ownerId }
|
self.users = responseData.filter { $0.id != residence.ownerId }
|
||||||
}
|
}
|
||||||
self.isLoadingUsers = false
|
self.isLoadingUsers = false
|
||||||
@@ -433,42 +501,238 @@ struct ResidenceFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - User Row Component
|
// MARK: - Organic Form Components
|
||||||
|
|
||||||
private struct UserRow: View {
|
private struct OrganicFormSection<Content: View>: View {
|
||||||
|
let title: String
|
||||||
|
let icon: String
|
||||||
|
@ViewBuilder let content: Content
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(title.uppercased())
|
||||||
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.tracking(1.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.padding(OrganicSpacing.cozy)
|
||||||
|
.background(
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
|
||||||
|
GeometryReader { geo in
|
||||||
|
OrganicBlobShape(variation: Int.random(in: 0...2))
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(colorScheme == .dark ? 0.06 : 0.03),
|
||||||
|
Color.appPrimary.opacity(0.01)
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.4
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.5)
|
||||||
|
.offset(x: geo.size.width * 0.5, y: -geo.size.height * 0.1)
|
||||||
|
.blur(radius: 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
GrainTexture(opacity: 0.012)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
.naturalShadow(.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct OrganicFormTextField: View {
|
||||||
|
let label: String
|
||||||
|
let placeholder: String
|
||||||
|
@Binding var text: String
|
||||||
|
var error: String? = nil
|
||||||
|
var keyboardType: UIKeyboardType = .default
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
|
||||||
|
TextField(placeholder, text: $text)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.keyboardType(keyboardType)
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.stroke(error != nil ? Color.appError : Color.appTextSecondary.opacity(0.1), lineWidth: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct OrganicFormTextArea: View {
|
||||||
|
let label: String
|
||||||
|
let placeholder: String
|
||||||
|
@Binding var text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
|
||||||
|
TextField(placeholder, text: $text, axis: .vertical)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.lineLimit(3...6)
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.stroke(Color.appTextSecondary.opacity(0.1), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct OrganicFormPicker<T: Hashable>: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var selection: T?
|
||||||
|
let options: [T]
|
||||||
|
let optionLabel: (T) -> String
|
||||||
|
let placeholder: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
Button(action: { selection = nil }) {
|
||||||
|
Text(placeholder)
|
||||||
|
}
|
||||||
|
ForEach(options, id: \.self) { option in
|
||||||
|
Button(action: { selection = option }) {
|
||||||
|
Text(optionLabel(option))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(selection.map { optionLabel($0) } ?? placeholder)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(selection == nil ? Color.appTextSecondary : Color.appTextPrimary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.stroke(Color.appTextSecondary.opacity(0.1), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct OrganicFormToggle: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var isOn: Bool
|
||||||
|
let icon: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(isOn ? Color.appAccent.opacity(0.15) : Color.appTextSecondary.opacity(0.1))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(isOn ? Color.appAccent : Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Toggle("", isOn: $isOn)
|
||||||
|
.labelsHidden()
|
||||||
|
.tint(Color.appPrimary)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct OrganicUserRow: View {
|
||||||
let user: ResidenceUserResponse
|
let user: ResidenceUserResponse
|
||||||
let isOwner: Bool
|
let isOwner: Bool
|
||||||
let onRemove: () -> Void
|
let onRemove: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack(spacing: 12) {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
ZStack {
|
||||||
HStack {
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
Text(String(user.username.prefix(1)).uppercased())
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
Text(user.username)
|
Text(user.username)
|
||||||
.font(.body)
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
if isOwner {
|
if isOwner {
|
||||||
Text("Owner")
|
Text("Owner")
|
||||||
.font(.caption)
|
.font(.system(size: 10, weight: .bold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
.background(Color.appPrimary)
|
.background(Color.appPrimary)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !user.email.isEmpty {
|
if !user.email.isEmpty {
|
||||||
Text(user.email)
|
Text(user.email)
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
|
||||||
let fullName = [user.firstName, user.lastName]
|
|
||||||
.compactMap { $0 }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
.joined(separator: " ")
|
|
||||||
if !fullName.isEmpty {
|
|
||||||
Text(fullName)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,12 +741,18 @@ private struct UserRow: View {
|
|||||||
if !isOwner {
|
if !isOwner {
|
||||||
Button(action: onRemove) {
|
Button(action: onRemove) {
|
||||||
Image(systemName: "trash")
|
Image(systemName: "trash")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
.foregroundColor(Color.appError)
|
.foregroundColor(Color.appError)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.appError.opacity(0.1))
|
||||||
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(12)
|
||||||
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ struct FeatureComparisonView: View {
|
|||||||
.padding(.bottom, AppSpacing.xl)
|
.padding(.bottom, AppSpacing.xl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
.background(WarmGradientBackground())
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
|||||||
@@ -11,15 +11,14 @@ struct UpgradeFeatureView: View {
|
|||||||
@State private var selectedProduct: Product?
|
@State private var selectedProduct: Product?
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var showSuccessAlert = false
|
@State private var showSuccessAlert = false
|
||||||
|
@State private var isAnimating = false
|
||||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||||
@StateObject private var storeKit = StoreKitManager.shared
|
@StateObject private var storeKit = StoreKitManager.shared
|
||||||
|
|
||||||
// Look up trigger data from cache
|
|
||||||
private var triggerData: UpgradeTriggerData? {
|
private var triggerData: UpgradeTriggerData? {
|
||||||
subscriptionCache.upgradeTriggers[triggerKey]
|
subscriptionCache.upgradeTriggers[triggerKey]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback values if trigger not found
|
|
||||||
private var title: String {
|
private var title: String {
|
||||||
triggerData?.title ?? "Upgrade Required"
|
triggerData?.title ?? "Upgrade Required"
|
||||||
}
|
}
|
||||||
@@ -33,55 +32,90 @@ struct UpgradeFeatureView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(spacing: AppSpacing.xl) {
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
// Icon
|
// Hero Section
|
||||||
Image(systemName: "star.circle.fill")
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
.font(.system(size: 60))
|
ZStack {
|
||||||
.foregroundStyle(Color.appAccent.gradient)
|
Circle()
|
||||||
.padding(.top, AppSpacing.xl)
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appAccent.opacity(0.2),
|
||||||
|
Color.appAccent.opacity(0.05),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 80
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 160, height: 160)
|
||||||
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
|
.animation(
|
||||||
|
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
|
||||||
|
value: isAnimating
|
||||||
|
)
|
||||||
|
|
||||||
// Title
|
ZStack {
|
||||||
Text(title)
|
Circle()
|
||||||
.font(.title2.weight(.bold))
|
.fill(
|
||||||
.foregroundColor(Color.appTextPrimary)
|
LinearGradient(
|
||||||
.multilineTextAlignment(.center)
|
colors: [Color.appAccent, Color.appAccent.opacity(0.8)],
|
||||||
.padding(.horizontal)
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
// Message
|
Image(systemName: "star.fill")
|
||||||
Text(message)
|
.font(.system(size: 36, weight: .medium))
|
||||||
.font(.body)
|
.foregroundColor(.white)
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
// Pro Features Preview - Dynamic content or fallback
|
|
||||||
Group {
|
|
||||||
if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty {
|
|
||||||
PromoContentView(content: promoContent)
|
|
||||||
.padding()
|
|
||||||
} else {
|
|
||||||
// Fallback to static features if no promo content
|
|
||||||
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
|
||||||
FeatureRow(icon: "house.fill", text: "Unlimited properties")
|
|
||||||
FeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
|
|
||||||
FeatureRow(icon: "person.2.fill", text: "Contractor management")
|
|
||||||
FeatureRow(icon: "doc.fill", text: "Document & warranty storage")
|
|
||||||
}
|
}
|
||||||
.padding()
|
.naturalShadow(.pronounced)
|
||||||
|
}
|
||||||
|
.padding(.top, OrganicSpacing.comfortable)
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundSecondary)
|
|
||||||
.cornerRadius(AppRadius.lg)
|
// Features Card
|
||||||
.padding(.horizontal)
|
VStack(spacing: 16) {
|
||||||
|
if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty {
|
||||||
|
PromoContentView(content: promoContent)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
OrganicUpgradeFeatureRow(icon: "house.fill", text: "Unlimited properties")
|
||||||
|
OrganicUpgradeFeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
|
||||||
|
OrganicUpgradeFeatureRow(icon: "person.2.fill", text: "Contractor management")
|
||||||
|
OrganicUpgradeFeatureRow(icon: "doc.fill", text: "Document & warranty storage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(OrganicSpacing.cozy)
|
||||||
|
.background(OrganicUpgradeCardBackground())
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
.naturalShadow(.medium)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
// Subscription Products
|
// Subscription Products
|
||||||
if storeKit.isLoading {
|
VStack(spacing: 12) {
|
||||||
ProgressView()
|
if storeKit.isLoading {
|
||||||
.tint(Color.appPrimary)
|
ProgressView()
|
||||||
.padding()
|
.tint(Color.appPrimary)
|
||||||
} else if !storeKit.products.isEmpty {
|
.padding()
|
||||||
VStack(spacing: AppSpacing.md) {
|
} else if !storeKit.products.isEmpty {
|
||||||
ForEach(storeKit.products, id: \.id) { product in
|
ForEach(storeKit.products, id: \.id) { product in
|
||||||
SubscriptionProductButton(
|
SubscriptionProductButton(
|
||||||
product: product,
|
product: product,
|
||||||
@@ -93,69 +127,64 @@ struct UpgradeFeatureView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
.padding(.horizontal)
|
Button(action: {
|
||||||
} else {
|
Task { await storeKit.loadProducts() }
|
||||||
// Fallback upgrade button if products fail to load
|
}) {
|
||||||
Button(action: {
|
HStack(spacing: 8) {
|
||||||
Task { await storeKit.loadProducts() }
|
Image(systemName: "arrow.clockwise")
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
if isProcessing {
|
|
||||||
ProgressView()
|
|
||||||
.tint(Color.appTextOnPrimary)
|
|
||||||
} else {
|
|
||||||
Text("Retry Loading Products")
|
Text("Retry Loading Products")
|
||||||
.fontWeight(.semibold)
|
.font(.system(size: 16, weight: .semibold))
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.background(Color.appPrimary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
|
||||||
.padding()
|
|
||||||
.background(Color.appPrimary)
|
|
||||||
.cornerRadius(AppRadius.md)
|
|
||||||
}
|
}
|
||||||
.disabled(isProcessing)
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
// Error Message
|
// Error Message
|
||||||
if let error = errorMessage {
|
if let error = errorMessage {
|
||||||
HStack {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
.foregroundColor(Color.appError)
|
.foregroundColor(Color.appError)
|
||||||
Text(error)
|
Text(error)
|
||||||
.font(.subheadline)
|
.font(.system(size: 14, weight: .medium))
|
||||||
.foregroundColor(Color.appError)
|
.foregroundColor(Color.appError)
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(16)
|
||||||
.background(Color.appError.opacity(0.1))
|
.background(Color.appError.opacity(0.1))
|
||||||
.cornerRadius(AppRadius.md)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.padding(.horizontal)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare Plans
|
// Links
|
||||||
Button(action: {
|
VStack(spacing: 12) {
|
||||||
showFeatureComparison = true
|
Button(action: {
|
||||||
}) {
|
showFeatureComparison = true
|
||||||
Text("Compare Free vs Pro")
|
}) {
|
||||||
.font(.subheadline)
|
Text("Compare Free vs Pro")
|
||||||
.foregroundColor(Color.appPrimary)
|
.font(.system(size: 15, weight: .semibold))
|
||||||
}
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
// Restore Purchases
|
Button(action: {
|
||||||
Button(action: {
|
handleRestore()
|
||||||
handleRestore()
|
}) {
|
||||||
}) {
|
Text("Restore Purchases")
|
||||||
Text("Restore Purchases")
|
.font(.system(size: 13, weight: .medium))
|
||||||
.font(.caption)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
.foregroundColor(Color.appTextSecondary)
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, AppSpacing.xxxl)
|
.padding(.bottom, OrganicSpacing.airy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color.appBackgroundPrimary)
|
.background(WarmGradientBackground())
|
||||||
.sheet(isPresented: $showFeatureComparison) {
|
.sheet(isPresented: $showFeatureComparison) {
|
||||||
FeatureComparisonView(isPresented: $showFeatureComparison)
|
FeatureComparisonView(isPresented: $showFeatureComparison)
|
||||||
}
|
}
|
||||||
@@ -165,10 +194,12 @@ struct UpgradeFeatureView: View {
|
|||||||
Text("You now have full access to all Pro features!")
|
Text("You now have full access to all Pro features!")
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
// Refresh subscription cache to get latest upgrade triggers
|
|
||||||
subscriptionCache.refreshFromCache()
|
subscriptionCache.refreshFromCache()
|
||||||
await storeKit.loadProducts()
|
await storeKit.loadProducts()
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
isAnimating = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handlePurchase(_ product: Product) {
|
private func handlePurchase(_ product: Product) {
|
||||||
@@ -183,7 +214,6 @@ struct UpgradeFeatureView: View {
|
|||||||
isProcessing = false
|
isProcessing = false
|
||||||
|
|
||||||
if transaction != nil {
|
if transaction != nil {
|
||||||
// Purchase successful
|
|
||||||
showSuccessAlert = true
|
showSuccessAlert = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,6 +246,64 @@ struct UpgradeFeatureView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Organic Feature Row
|
||||||
|
|
||||||
|
private struct OrganicUpgradeFeatureRow: View {
|
||||||
|
let icon: String
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Organic Card Background
|
||||||
|
|
||||||
|
private struct OrganicUpgradeCardBackground: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
|
||||||
|
GeometryReader { geo in
|
||||||
|
OrganicBlobShape(variation: 2)
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appAccent.opacity(colorScheme == .dark ? 0.08 : 0.05),
|
||||||
|
Color.appAccent.opacity(0.01)
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.5
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.6)
|
||||||
|
.offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.4)
|
||||||
|
.blur(radius: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
GrainTexture(opacity: 0.015)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
UpgradeFeatureView(
|
UpgradeFeatureView(
|
||||||
triggerKey: "view_contractors",
|
triggerKey: "view_contractors",
|
||||||
|
|||||||
@@ -21,30 +21,35 @@ struct PromoContentView: View {
|
|||||||
|
|
||||||
case .title(let text):
|
case .title(let text):
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.title3.bold())
|
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||||
.foregroundColor(Color.appPrimary)
|
.foregroundColor(Color.appPrimary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
case .body(let text):
|
case .body(let text):
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.subheadline)
|
.font(.system(size: 14, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
case .checkItem(let text):
|
case .checkItem(let text):
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 10) {
|
||||||
Image(systemName: "checkmark")
|
ZStack {
|
||||||
.font(.system(size: 14, weight: .bold))
|
Circle()
|
||||||
.foregroundColor(Color.appPrimary)
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.subheadline)
|
.font(.system(size: 14, weight: .medium))
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
case .italic(let text):
|
case .italic(let text):
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .medium))
|
||||||
.italic()
|
.italic()
|
||||||
.foregroundColor(Color.appAccent)
|
.foregroundColor(Color.appAccent)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -78,15 +83,12 @@ struct PromoContentView: View {
|
|||||||
let text = trimmed.dropFirst().trimmingCharacters(in: .whitespaces)
|
let text = trimmed.dropFirst().trimmingCharacters(in: .whitespaces)
|
||||||
result.append(.checkItem(text))
|
result.append(.checkItem(text))
|
||||||
} else if trimmed.contains("<b>") && trimmed.contains("</b>") {
|
} else if trimmed.contains("<b>") && trimmed.contains("</b>") {
|
||||||
// Title line with emoji
|
|
||||||
let cleaned = trimmed
|
let cleaned = trimmed
|
||||||
.replacingOccurrences(of: "<b>", with: "")
|
.replacingOccurrences(of: "<b>", with: "")
|
||||||
.replacingOccurrences(of: "</b>", with: "")
|
.replacingOccurrences(of: "</b>", with: "")
|
||||||
|
|
||||||
// Check if starts with emoji
|
|
||||||
if let firstScalar = cleaned.unicodeScalars.first,
|
if let firstScalar = cleaned.unicodeScalars.first,
|
||||||
firstScalar.properties.isEmoji && !firstScalar.properties.isASCIIHexDigit {
|
firstScalar.properties.isEmoji && !firstScalar.properties.isASCIIHexDigit {
|
||||||
// Split emoji and title
|
|
||||||
let parts = cleaned.split(separator: " ", maxSplits: 1)
|
let parts = cleaned.split(separator: " ", maxSplits: 1)
|
||||||
if parts.count == 2 {
|
if parts.count == 2 {
|
||||||
result.append(.emoji(String(parts[0])))
|
result.append(.emoji(String(parts[0])))
|
||||||
@@ -104,7 +106,6 @@ struct PromoContentView: View {
|
|||||||
result.append(.italic(text))
|
result.append(.italic(text))
|
||||||
} else if trimmed.first?.unicodeScalars.first?.properties.isEmoji == true &&
|
} else if trimmed.first?.unicodeScalars.first?.properties.isEmoji == true &&
|
||||||
trimmed.count <= 2 {
|
trimmed.count <= 2 {
|
||||||
// Standalone emoji
|
|
||||||
result.append(.emoji(trimmed))
|
result.append(.emoji(trimmed))
|
||||||
} else {
|
} else {
|
||||||
result.append(.body(trimmed))
|
result.append(.body(trimmed))
|
||||||
@@ -126,6 +127,7 @@ struct UpgradePromptView: View {
|
|||||||
@State private var selectedProduct: Product?
|
@State private var selectedProduct: Product?
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var showSuccessAlert = false
|
@State private var showSuccessAlert = false
|
||||||
|
@State private var isAnimating = false
|
||||||
|
|
||||||
var triggerData: UpgradeTriggerData? {
|
var triggerData: UpgradeTriggerData? {
|
||||||
subscriptionCache.upgradeTriggers[triggerKey]
|
subscriptionCache.upgradeTriggers[triggerKey]
|
||||||
@@ -133,133 +135,171 @@ struct UpgradePromptView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ZStack {
|
||||||
VStack(spacing: AppSpacing.xl) {
|
WarmGradientBackground()
|
||||||
// Icon
|
|
||||||
Image(systemName: "star.circle.fill")
|
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundStyle(Color.appAccent.gradient)
|
|
||||||
.padding(.top, AppSpacing.xl)
|
|
||||||
|
|
||||||
// Title
|
ScrollView(showsIndicators: false) {
|
||||||
Text(triggerData?.title ?? "Upgrade to Pro")
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
.font(.title2.weight(.bold))
|
// Hero Section
|
||||||
.foregroundColor(Color.appTextPrimary)
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
.multilineTextAlignment(.center)
|
ZStack {
|
||||||
.padding(.horizontal)
|
Circle()
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appAccent.opacity(0.2),
|
||||||
|
Color.appAccent.opacity(0.05),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 80
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 160, height: 160)
|
||||||
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
|
.animation(
|
||||||
|
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
|
||||||
|
value: isAnimating
|
||||||
|
)
|
||||||
|
|
||||||
// Message
|
ZStack {
|
||||||
Text(triggerData?.message ?? "Unlock unlimited access to all features")
|
Circle()
|
||||||
.font(.body)
|
.fill(
|
||||||
.foregroundColor(Color.appTextSecondary)
|
LinearGradient(
|
||||||
.multilineTextAlignment(.center)
|
colors: [Color.appAccent, Color.appAccent.opacity(0.8)],
|
||||||
.padding(.horizontal)
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
// Pro Features Preview - Dynamic content or fallback
|
Image(systemName: "star.fill")
|
||||||
Group {
|
.font(.system(size: 36, weight: .medium))
|
||||||
if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty {
|
.foregroundColor(.white)
|
||||||
PromoContentView(content: promoContent)
|
}
|
||||||
.padding()
|
.naturalShadow(.pronounced)
|
||||||
} else {
|
|
||||||
// Fallback to static features if no promo content
|
|
||||||
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
|
||||||
FeatureRow(icon: "house.fill", text: "Unlimited properties")
|
|
||||||
FeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
|
|
||||||
FeatureRow(icon: "person.2.fill", text: "Contractor management")
|
|
||||||
FeatureRow(icon: "doc.fill", text: "Document & warranty storage")
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(.top, OrganicSpacing.comfortable)
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(Color.appBackgroundSecondary)
|
|
||||||
.cornerRadius(AppRadius.lg)
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
// Subscription Products
|
VStack(spacing: 8) {
|
||||||
if storeKit.isLoading {
|
Text(triggerData?.title ?? "Upgrade to Pro")
|
||||||
ProgressView()
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
.tint(Color.appPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
.padding()
|
.multilineTextAlignment(.center)
|
||||||
} else if !storeKit.products.isEmpty {
|
|
||||||
VStack(spacing: AppSpacing.md) {
|
Text(triggerData?.message ?? "Unlock unlimited access to all features")
|
||||||
ForEach(storeKit.products, id: \.id) { product in
|
.font(.system(size: 15, weight: .medium))
|
||||||
SubscriptionProductButton(
|
.foregroundColor(Color.appTextSecondary)
|
||||||
product: product,
|
.multilineTextAlignment(.center)
|
||||||
isSelected: selectedProduct?.id == product.id,
|
.padding(.horizontal)
|
||||||
isProcessing: isProcessing,
|
|
||||||
onSelect: {
|
|
||||||
selectedProduct = product
|
|
||||||
handlePurchase(product)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
|
||||||
} else {
|
// Features Card
|
||||||
// Fallback upgrade button if products fail to load
|
VStack(spacing: 16) {
|
||||||
Button(action: {
|
if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty {
|
||||||
Task { await storeKit.loadProducts() }
|
PromoContentView(content: promoContent)
|
||||||
}) {
|
} else {
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
if isProcessing {
|
OrganicFeatureRow(icon: "house.fill", text: "Unlimited properties")
|
||||||
ProgressView()
|
OrganicFeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
|
||||||
.tint(Color.appTextOnPrimary)
|
OrganicFeatureRow(icon: "person.2.fill", text: "Contractor management")
|
||||||
} else {
|
OrganicFeatureRow(icon: "doc.fill", text: "Document & warranty storage")
|
||||||
Text("Retry Loading Products")
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
|
||||||
.padding()
|
|
||||||
.background(Color.appPrimary)
|
|
||||||
.cornerRadius(AppRadius.md)
|
|
||||||
}
|
}
|
||||||
.disabled(isProcessing)
|
.padding(OrganicSpacing.cozy)
|
||||||
.padding(.horizontal)
|
.background(OrganicCardBackground())
|
||||||
}
|
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
.naturalShadow(.medium)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
// Error Message
|
// Subscription Products
|
||||||
if let error = errorMessage {
|
VStack(spacing: 12) {
|
||||||
HStack {
|
if storeKit.isLoading {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
ProgressView()
|
||||||
.foregroundColor(Color.appError)
|
.tint(Color.appPrimary)
|
||||||
Text(error)
|
.padding()
|
||||||
.font(.subheadline)
|
} else if !storeKit.products.isEmpty {
|
||||||
.foregroundColor(Color.appError)
|
ForEach(storeKit.products, id: \.id) { product in
|
||||||
|
OrganicSubscriptionButton(
|
||||||
|
product: product,
|
||||||
|
isSelected: selectedProduct?.id == product.id,
|
||||||
|
isProcessing: isProcessing,
|
||||||
|
onSelect: {
|
||||||
|
selectedProduct = product
|
||||||
|
handlePurchase(product)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(action: {
|
||||||
|
Task { await storeKit.loadProducts() }
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
Text("Retry Loading Products")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.background(Color.appPrimary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(.horizontal, 16)
|
||||||
.background(Color.appError.opacity(0.1))
|
|
||||||
.cornerRadius(AppRadius.md)
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare Plans
|
// Error Message
|
||||||
Button(action: {
|
if let error = errorMessage {
|
||||||
showFeatureComparison = true
|
HStack(spacing: 10) {
|
||||||
}) {
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
Text("Compare Free vs Pro")
|
.foregroundColor(Color.appError)
|
||||||
.font(.subheadline)
|
Text(error)
|
||||||
.foregroundColor(Color.appPrimary)
|
.font(.system(size: 14, weight: .medium))
|
||||||
}
|
.foregroundColor(Color.appError)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appError.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
// Restore Purchases
|
// Links
|
||||||
Button(action: {
|
VStack(spacing: 12) {
|
||||||
handleRestore()
|
Button(action: {
|
||||||
}) {
|
showFeatureComparison = true
|
||||||
Text("Restore Purchases")
|
}) {
|
||||||
.font(.caption)
|
Text("Compare Free vs Pro")
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
handleRestore()
|
||||||
|
}) {
|
||||||
|
Text("Restore Purchases")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, OrganicSpacing.airy)
|
||||||
}
|
}
|
||||||
.padding(.bottom, AppSpacing.xl)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Cancel") {
|
Button(action: { isPresented = false }) {
|
||||||
isPresented = false
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.appBackgroundSecondary.opacity(0.8))
|
||||||
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,10 +314,12 @@ struct UpgradePromptView: View {
|
|||||||
Text("You now have full access to all Pro features!")
|
Text("You now have full access to all Pro features!")
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
// Refresh subscription cache to get latest upgrade triggers
|
|
||||||
subscriptionCache.refreshFromCache()
|
subscriptionCache.refreshFromCache()
|
||||||
await storeKit.loadProducts()
|
await storeKit.loadProducts()
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
isAnimating = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +335,6 @@ struct UpgradePromptView: View {
|
|||||||
isProcessing = false
|
isProcessing = false
|
||||||
|
|
||||||
if transaction != nil {
|
if transaction != nil {
|
||||||
// Purchase successful
|
|
||||||
showSuccessAlert = true
|
showSuccessAlert = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,6 +367,144 @@ struct UpgradePromptView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Organic Feature Row
|
||||||
|
|
||||||
|
private struct OrganicFeatureRow: View {
|
||||||
|
let icon: String
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Organic Subscription Button
|
||||||
|
|
||||||
|
private struct OrganicSubscriptionButton: View {
|
||||||
|
let product: Product
|
||||||
|
let isSelected: Bool
|
||||||
|
let isProcessing: Bool
|
||||||
|
let onSelect: () -> Void
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
var isAnnual: Bool {
|
||||||
|
product.id.contains("annual")
|
||||||
|
}
|
||||||
|
|
||||||
|
var savingsText: String? {
|
||||||
|
if isAnnual {
|
||||||
|
return "Save 17%"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onSelect) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(product.displayName)
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.foregroundColor(isAnnual ? Color.appTextOnPrimary : Color.appTextPrimary)
|
||||||
|
|
||||||
|
if let savings = savingsText {
|
||||||
|
Text(savings)
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundColor(isAnnual ? Color.white.opacity(0.9) : Color.appPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if isProcessing && isSelected {
|
||||||
|
ProgressView()
|
||||||
|
.tint(isAnnual ? .white : Color.appPrimary)
|
||||||
|
} else {
|
||||||
|
Text(product.displayPrice)
|
||||||
|
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(isAnnual ? Color.appTextOnPrimary : Color.appPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(18)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(
|
||||||
|
ZStack {
|
||||||
|
if isAnnual {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isAnnual {
|
||||||
|
GrainTexture(opacity: 0.01)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.stroke(isAnnual ? Color.appAccent : Color.appTextSecondary.opacity(0.15), lineWidth: isAnnual ? 2 : 1)
|
||||||
|
)
|
||||||
|
.shadow(
|
||||||
|
color: isAnnual ? Color.appPrimary.opacity(0.3) : Color.black.opacity(colorScheme == .dark ? 0.3 : 0.08),
|
||||||
|
radius: isAnnual ? 12 : 8,
|
||||||
|
y: isAnnual ? 6 : 4
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.disabled(isProcessing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Organic Card Background
|
||||||
|
|
||||||
|
private struct OrganicCardBackground: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
|
||||||
|
GeometryReader { geo in
|
||||||
|
OrganicBlobShape(variation: 1)
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
|
||||||
|
Color.appPrimary.opacity(0.01)
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.5
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.5)
|
||||||
|
.offset(x: geo.size.width * 0.4, y: -geo.size.height * 0.1)
|
||||||
|
.blur(radius: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
GrainTexture(opacity: 0.015)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SubscriptionProductButton: View {
|
struct SubscriptionProductButton: View {
|
||||||
let product: Product
|
let product: Product
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
@@ -344,60 +523,21 @@ struct SubscriptionProductButton: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onSelect) {
|
OrganicSubscriptionButton(
|
||||||
HStack {
|
product: product,
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
isSelected: isSelected,
|
||||||
Text(product.displayName)
|
isProcessing: isProcessing,
|
||||||
.font(.headline)
|
onSelect: onSelect
|
||||||
.foregroundColor(Color.appTextPrimary)
|
)
|
||||||
|
|
||||||
if let savings = savingsText {
|
|
||||||
Text(savings)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(Color.appPrimary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if isProcessing && isSelected {
|
|
||||||
ProgressView()
|
|
||||||
.tint(Color.appTextOnPrimary)
|
|
||||||
} else {
|
|
||||||
Text(product.displayPrice)
|
|
||||||
.font(.title3.weight(.bold))
|
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.background(isAnnual ? Color.appPrimary : Color.appSecondary)
|
|
||||||
.cornerRadius(AppRadius.md)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: AppRadius.md)
|
|
||||||
.stroke(isAnnual ? Color.appAccent : Color.clear, lineWidth: 2)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.disabled(isProcessing)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FeatureRow: View {
|
struct FeatureRow: View {
|
||||||
let icon: String
|
let icon: String
|
||||||
let text: String
|
let text: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: AppSpacing.md) {
|
OrganicFeatureRow(icon: icon, text: text)
|
||||||
Image(systemName: icon)
|
|
||||||
.foregroundColor(Color.appPrimary)
|
|
||||||
.frame(width: 24)
|
|
||||||
|
|
||||||
Text(text)
|
|
||||||
.font(.body)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,25 +5,34 @@ struct ErrorView: View {
|
|||||||
let retryAction: () -> Void
|
let retryAction: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: OrganicSpacing.cozy) {
|
||||||
Image(systemName: "exclamationmark.triangle")
|
ZStack {
|
||||||
.font(.system(size: 64))
|
Circle()
|
||||||
.foregroundColor(Color.appError)
|
.fill(Color.appError.opacity(0.1))
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.system(size: 44, weight: .medium))
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
}
|
||||||
|
|
||||||
Text("Error: \(message)")
|
Text("Error: \(message)")
|
||||||
.foregroundColor(Color.appError)
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
Button(action: retryAction) {
|
Button(action: retryAction) {
|
||||||
Text("Retry")
|
Text("Retry")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 14)
|
||||||
.background(Color.appPrimary)
|
.background(Color.appPrimary)
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
.cornerRadius(8)
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
.naturalShadow(.subtle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(OrganicSpacing.comfortable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,23 +7,23 @@ struct StatView: View {
|
|||||||
var color: Color = Color.appPrimary
|
var color: Color = Color.appPrimary
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: AppSpacing.sm) {
|
VStack(spacing: OrganicSpacing.compact) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(color.opacity(0.1))
|
.fill(color.opacity(0.1))
|
||||||
.frame(width: 48, height: 48)
|
.frame(width: 52, height: 52)
|
||||||
|
|
||||||
if icon == "house_outline" {
|
if icon == "house_outline" {
|
||||||
Image("house_outline")
|
Image("house_outline")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 22, height: 22)
|
.frame(width: 24, height: 24)
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
.background(content: {
|
.background(content: {
|
||||||
RoundedRectangle(cornerRadius: AppRadius.sm)
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
.fill(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
.fill(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
.frame(width: 22, height: 22)
|
.frame(width: 24, height: 24)
|
||||||
.shadow(color: Color.appPrimary.opacity(0.3), radius: 6, y: 3)
|
|
||||||
})
|
})
|
||||||
|
.naturalShadow(.subtle)
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 22, weight: .semibold))
|
.font(.system(size: 22, weight: .semibold))
|
||||||
@@ -32,12 +32,11 @@ struct StatView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.title2.weight(.semibold))
|
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.footnote.weight(.medium))
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,23 @@ import SwiftUI
|
|||||||
|
|
||||||
struct EmptyResidencesView: View {
|
struct EmptyResidencesView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: OrganicSpacing.cozy) {
|
||||||
Image(systemName: "house")
|
ZStack {
|
||||||
.font(.system(size: 80))
|
Circle()
|
||||||
.foregroundColor(Color.appPrimary.opacity(0.6))
|
.fill(Color.appPrimary.opacity(0.08))
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
|
Image(systemName: "house")
|
||||||
|
.font(.system(size: 56, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary.opacity(0.6))
|
||||||
|
}
|
||||||
|
|
||||||
Text("No properties yet")
|
Text("No properties yet")
|
||||||
.font(.title2)
|
.font(.system(size: 20, weight: .semibold, design: .rounded))
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
Text("Add your first property to get started!")
|
Text("Add your first property to get started!")
|
||||||
.font(.body)
|
.font(.system(size: 15, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,18 +6,23 @@ struct SummaryStatView: View {
|
|||||||
let label: String
|
let label: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: OrganicSpacing.compact) {
|
||||||
Image(systemName: icon)
|
ZStack {
|
||||||
.font(.title3)
|
Circle()
|
||||||
.foregroundColor(Color.appPrimary)
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.title2)
|
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ struct CompletionCardView: View {
|
|||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.background(Color.appAccent.opacity(0.1))
|
.background(Color.appAccent.opacity(0.1))
|
||||||
.cornerRadius(6)
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +88,13 @@ struct CompletionCardView: View {
|
|||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(Color.appPrimary.opacity(0.1))
|
.background(Color.appPrimary.opacity(0.1))
|
||||||
.foregroundColor(Color.appPrimary)
|
.foregroundColor(Color.appPrimary)
|
||||||
.cornerRadius(8)
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(14)
|
||||||
.background(Color.appBackgroundSecondary.opacity(0.5))
|
.background(Color.appBackgroundSecondary.opacity(0.5))
|
||||||
.cornerRadius(8)
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
.sheet(isPresented: $showPhotoSheet) {
|
.sheet(isPresented: $showPhotoSheet) {
|
||||||
PhotoViewerSheet(images: completion.images)
|
PhotoViewerSheet(images: completion.images)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,13 +38,12 @@ struct DynamicTaskColumnView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Text("\(column.count)")
|
Text("\(column.count)")
|
||||||
.font(.caption)
|
.font(.system(size: 12, weight: .semibold))
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 5)
|
||||||
.background(columnColor)
|
.background(columnColor)
|
||||||
.cornerRadius(12)
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
|
|
||||||
if column.tasks.isEmpty {
|
if column.tasks.isEmpty {
|
||||||
|
|||||||
@@ -2,19 +2,26 @@ import SwiftUI
|
|||||||
|
|
||||||
struct EmptyTasksView: View {
|
struct EmptyTasksView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: OrganicSpacing.cozy) {
|
||||||
Image(systemName: "checkmark.circle")
|
ZStack {
|
||||||
.font(.system(size: 48))
|
Circle()
|
||||||
.foregroundColor(Color.appTextSecondary.opacity(0.5))
|
.fill(Color.appPrimary.opacity(0.08))
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
|
Image(systemName: "checkmark.circle")
|
||||||
|
.font(.system(size: 36, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary.opacity(0.5))
|
||||||
|
}
|
||||||
|
|
||||||
Text("No tasks yet")
|
Text("No tasks yet")
|
||||||
.font(.subheadline)
|
.font(.system(size: 15, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(32)
|
.padding(OrganicSpacing.spacious)
|
||||||
.background(Color.appBackgroundSecondary)
|
.background(Color.appBackgroundSecondary)
|
||||||
.cornerRadius(12)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.naturalShadow(.subtle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,9 @@ struct AllTasksView: View {
|
|||||||
@State private var selectedTaskForCancel: TaskResponse?
|
@State private var selectedTaskForCancel: TaskResponse?
|
||||||
@State private var showCancelConfirmation = false
|
@State private var showCancelConfirmation = false
|
||||||
|
|
||||||
// Deep link task ID to open (from push notification)
|
|
||||||
@State private var pendingTaskId: Int32?
|
@State private var pendingTaskId: Int32?
|
||||||
// Column index to scroll to (for deep link navigation)
|
|
||||||
@State private var scrollToColumnIndex: Int?
|
@State private var scrollToColumnIndex: Int?
|
||||||
|
|
||||||
// Use ViewModel's computed properties
|
|
||||||
private var totalTaskCount: Int { taskViewModel.totalTaskCount }
|
private var totalTaskCount: Int { taskViewModel.totalTaskCount }
|
||||||
private var hasNoTasks: Bool { taskViewModel.hasNoTasks }
|
private var hasNoTasks: Bool { taskViewModel.hasNoTasks }
|
||||||
private var hasTasks: Bool { taskViewModel.hasTasks }
|
private var hasTasks: Bool { taskViewModel.hasTasks }
|
||||||
@@ -109,12 +106,10 @@ struct AllTasksView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
PostHogAnalytics.shared.screen(AnalyticsEvents.taskScreenShown)
|
PostHogAnalytics.shared.screen(AnalyticsEvents.taskScreenShown)
|
||||||
|
|
||||||
// Check for pending navigation from push notification (app launched from notification)
|
|
||||||
if let taskId = PushNotificationManager.shared.pendingNavigationTaskId {
|
if let taskId = PushNotificationManager.shared.pendingNavigationTaskId {
|
||||||
pendingTaskId = Int32(taskId)
|
pendingTaskId = Int32(taskId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if widget completed a task - force refresh if dirty
|
|
||||||
if WidgetDataManager.shared.areTasksDirty() {
|
if WidgetDataManager.shared.areTasksDirty() {
|
||||||
WidgetDataManager.shared.clearDirtyFlag()
|
WidgetDataManager.shared.clearDirtyFlag()
|
||||||
loadAllTasks(forceRefresh: true)
|
loadAllTasks(forceRefresh: true)
|
||||||
@@ -123,43 +118,29 @@ struct AllTasksView: View {
|
|||||||
}
|
}
|
||||||
residenceViewModel.loadMyResidences()
|
residenceViewModel.loadMyResidences()
|
||||||
}
|
}
|
||||||
// Handle push notification deep links
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { notification in
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { notification in
|
||||||
print("📬 AllTasksView received .navigateToTask notification")
|
|
||||||
if let userInfo = notification.userInfo,
|
if let userInfo = notification.userInfo,
|
||||||
let taskId = userInfo["taskId"] as? Int {
|
let taskId = userInfo["taskId"] as? Int {
|
||||||
print("📬 Setting pendingTaskId to \(taskId)")
|
|
||||||
pendingTaskId = Int32(taskId)
|
pendingTaskId = Int32(taskId)
|
||||||
// If tasks are already loaded, try to navigate immediately
|
|
||||||
if let response = tasksResponse {
|
if let response = tasksResponse {
|
||||||
print("📬 Tasks already loaded, attempting immediate navigation")
|
|
||||||
navigateToTaskInKanban(taskId: Int32(taskId), response: response)
|
navigateToTaskInKanban(taskId: Int32(taskId), response: response)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
print("📬 Failed to extract taskId from notification userInfo: \(notification.userInfo ?? [:])")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { notification in
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { notification in
|
||||||
print("📬 AllTasksView received .navigateToEditTask notification")
|
|
||||||
if let userInfo = notification.userInfo,
|
if let userInfo = notification.userInfo,
|
||||||
let taskId = userInfo["taskId"] as? Int {
|
let taskId = userInfo["taskId"] as? Int {
|
||||||
print("📬 Setting pendingTaskId to \(taskId)")
|
|
||||||
pendingTaskId = Int32(taskId)
|
pendingTaskId = Int32(taskId)
|
||||||
// If tasks are already loaded, try to navigate immediately
|
|
||||||
if let response = tasksResponse {
|
if let response = tasksResponse {
|
||||||
print("📬 Tasks already loaded, attempting immediate navigation")
|
|
||||||
navigateToTaskInKanban(taskId: Int32(taskId), response: response)
|
navigateToTaskInKanban(taskId: Int32(taskId), response: response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// When tasks load and we have a pending task ID, scroll to column and open the edit sheet
|
|
||||||
.onChange(of: tasksResponse) { response in
|
.onChange(of: tasksResponse) { response in
|
||||||
print("📬 tasksResponse changed, pendingTaskId=\(pendingTaskId?.description ?? "nil")")
|
|
||||||
if let taskId = pendingTaskId, let response = response {
|
if let taskId = pendingTaskId, let response = response {
|
||||||
navigateToTaskInKanban(taskId: taskId, response: response)
|
navigateToTaskInKanban(taskId: taskId, response: response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Check dirty flag when app returns from background (widget may have completed a task)
|
|
||||||
.onChange(of: scenePhase) { newPhase in
|
.onChange(of: scenePhase) { newPhase in
|
||||||
if newPhase == .active {
|
if newPhase == .active {
|
||||||
if WidgetDataManager.shared.areTasksDirty() {
|
if WidgetDataManager.shared.areTasksDirty() {
|
||||||
@@ -173,9 +154,8 @@ struct AllTasksView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var mainContent: some View {
|
private var mainContent: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.appBackgroundPrimary
|
WarmGradientBackground()
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
if hasNoTasks && isLoadingTasks {
|
if hasNoTasks && isLoadingTasks {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
} else if let error = tasksError {
|
} else if let error = tasksError {
|
||||||
@@ -184,55 +164,13 @@ struct AllTasksView: View {
|
|||||||
}
|
}
|
||||||
} else if let tasksResponse = tasksResponse {
|
} else if let tasksResponse = tasksResponse {
|
||||||
if hasNoTasks {
|
if hasNoTasks {
|
||||||
// Empty state with big button
|
OrganicEmptyTasksView(
|
||||||
VStack(spacing: 24) {
|
totalTaskCount: totalTaskCount,
|
||||||
Spacer()
|
hasResidences: !(residenceViewModel.myResidences?.residences.isEmpty ?? true),
|
||||||
|
subscriptionCache: subscriptionCache,
|
||||||
Image(systemName: "checklist")
|
showingUpgradePrompt: $showingUpgradePrompt,
|
||||||
.font(.system(size: 64))
|
showAddTask: $showAddTask
|
||||||
.foregroundStyle(Color.appPrimary.opacity(0.6))
|
)
|
||||||
|
|
||||||
Text(L10n.Tasks.noTasksYet)
|
|
||||||
.font(.title2)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(Color.appTextPrimary)
|
|
||||||
|
|
||||||
Text(L10n.Tasks.createFirst)
|
|
||||||
.font(.body)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
// Check if we should show upgrade prompt before adding
|
|
||||||
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
|
|
||||||
showingUpgradePrompt = true
|
|
||||||
} else {
|
|
||||||
showAddTask = true
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
Text(L10n.Tasks.addButton)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: 50)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.controlSize(.large)
|
|
||||||
.padding(.horizontal, 48)
|
|
||||||
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
|
|
||||||
|
|
||||||
if residenceViewModel.myResidences?.residences.isEmpty ?? true {
|
|
||||||
Text(L10n.Tasks.addPropertyFirst)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(Color.appError)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
} else {
|
} else {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
@@ -277,7 +215,6 @@ struct AllTasksView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Show swipe hint on first column when it's empty but others have tasks
|
|
||||||
if index == 0 && shouldShowSwipeHint {
|
if index == 0 && shouldShowSwipeHint {
|
||||||
SwipeHintView()
|
SwipeHintView()
|
||||||
}
|
}
|
||||||
@@ -300,7 +237,6 @@ struct AllTasksView: View {
|
|||||||
withAnimation(.easeInOut(duration: 0.3)) {
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
proxy.scrollTo(columnIndex, anchor: .leading)
|
proxy.scrollTo(columnIndex, anchor: .leading)
|
||||||
}
|
}
|
||||||
// Clear after scrolling
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
scrollToColumnIndex = nil
|
scrollToColumnIndex = nil
|
||||||
}
|
}
|
||||||
@@ -310,35 +246,33 @@ struct AllTasksView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(Color.appBackgroundPrimary)
|
|
||||||
.navigationTitle(L10n.Tasks.allTasks)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(action: {
|
HStack(spacing: 12) {
|
||||||
// Check if we should show upgrade prompt before adding
|
Button(action: {
|
||||||
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
|
loadAllTasks(forceRefresh: true)
|
||||||
showingUpgradePrompt = true
|
}) {
|
||||||
} else {
|
Image(systemName: "arrow.clockwise")
|
||||||
showAddTask = true
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.rotationEffect(.degrees(isLoadingTasks ? 360 : 0))
|
||||||
|
.animation(isLoadingTasks ? .linear(duration: 0.5).repeatForever(autoreverses: false) : .default, value: isLoadingTasks)
|
||||||
}
|
}
|
||||||
}) {
|
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true || isLoadingTasks)
|
||||||
Image(systemName: "plus")
|
|
||||||
|
Button(action: {
|
||||||
|
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
|
||||||
|
showingUpgradePrompt = true
|
||||||
|
} else {
|
||||||
|
showAddTask = true
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
OrganicToolbarAddButton()
|
||||||
|
}
|
||||||
|
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
|
||||||
}
|
}
|
||||||
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
|
|
||||||
}
|
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button(action: {
|
|
||||||
loadAllTasks(forceRefresh: true)
|
|
||||||
}) {
|
|
||||||
Image(systemName: "arrow.clockwise")
|
|
||||||
.rotationEffect(.degrees(isLoadingTasks ? 360 : 0))
|
|
||||||
.animation(isLoadingTasks ? .linear(duration: 0.5).repeatForever(autoreverses: false) : .default, value: isLoadingTasks)
|
|
||||||
}
|
|
||||||
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true || isLoadingTasks)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: taskViewModel.isLoading) { isLoading in
|
.onChange(of: taskViewModel.isLoading) { isLoading in
|
||||||
@@ -347,7 +281,7 @@ struct AllTasksView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadAllTasks(forceRefresh: Bool = false) {
|
private func loadAllTasks(forceRefresh: Bool = false) {
|
||||||
taskViewModel.loadTasks(forceRefresh: forceRefresh)
|
taskViewModel.loadTasks(forceRefresh: forceRefresh)
|
||||||
}
|
}
|
||||||
@@ -357,33 +291,157 @@ struct AllTasksView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func navigateToTaskInKanban(taskId: Int32, response: TaskColumnsResponse) {
|
private func navigateToTaskInKanban(taskId: Int32, response: TaskColumnsResponse) {
|
||||||
print("📬 navigateToTaskInKanban called with taskId=\(taskId)")
|
|
||||||
|
|
||||||
// Find which column contains the task
|
|
||||||
for (index, column) in response.columns.enumerated() {
|
for (index, column) in response.columns.enumerated() {
|
||||||
if column.tasks.contains(where: { $0.id == taskId }) {
|
if column.tasks.contains(where: { $0.id == taskId }) {
|
||||||
print("📬 Found task in column \(index) '\(column.name)'")
|
|
||||||
|
|
||||||
// Clear pending
|
|
||||||
pendingTaskId = nil
|
pendingTaskId = nil
|
||||||
PushNotificationManager.shared.clearPendingNavigation()
|
PushNotificationManager.shared.clearPendingNavigation()
|
||||||
|
|
||||||
// Small delay to ensure view is ready, then scroll
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
self.scrollToColumnIndex = index
|
self.scrollToColumnIndex = index
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task not found
|
|
||||||
print("📬 Task with id=\(taskId) not found")
|
|
||||||
pendingTaskId = nil
|
pendingTaskId = nil
|
||||||
PushNotificationManager.shared.clearPendingNavigation()
|
PushNotificationManager.shared.clearPendingNavigation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extension to apply corner radius to specific corners
|
// MARK: - Organic Empty Tasks View
|
||||||
|
|
||||||
|
private struct OrganicEmptyTasksView: View {
|
||||||
|
let totalTaskCount: Int
|
||||||
|
let hasResidences: Bool
|
||||||
|
let subscriptionCache: SubscriptionCacheWrapper
|
||||||
|
@Binding var showingUpgradePrompt: Bool
|
||||||
|
@Binding var showAddTask: Bool
|
||||||
|
@State private var isAnimating = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(0.15),
|
||||||
|
Color.appPrimary.opacity(0.05),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 80
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 160, height: 160)
|
||||||
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
|
.animation(
|
||||||
|
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true),
|
||||||
|
value: isAnimating
|
||||||
|
)
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
|
||||||
|
Image(systemName: "checklist")
|
||||||
|
.font(.system(size: 44, weight: .medium))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
.offset(y: isAnimating ? -2 : 2)
|
||||||
|
.animation(
|
||||||
|
Animation.easeInOut(duration: 2).repeatForever(autoreverses: true),
|
||||||
|
value: isAnimating
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text(L10n.Tasks.noTasksYet)
|
||||||
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
|
Text(L10n.Tasks.createFirst)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineSpacing(4)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
|
||||||
|
showingUpgradePrompt = true
|
||||||
|
} else {
|
||||||
|
showAddTask = true
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
Text(L10n.Tasks.addButton)
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.shadow(color: Color.appPrimary.opacity(0.3), radius: 12, y: 6)
|
||||||
|
}
|
||||||
|
.disabled(!hasResidences)
|
||||||
|
.padding(.horizontal, 48)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
|
||||||
|
|
||||||
|
if !hasResidences {
|
||||||
|
Text(L10n.Tasks.addPropertyFirst)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 40) {
|
||||||
|
FloatingLeaf(delay: 0, size: 18, color: Color.appPrimary)
|
||||||
|
FloatingLeaf(delay: 0.5, size: 14, color: Color.appAccent)
|
||||||
|
FloatingLeaf(delay: 1.0, size: 20, color: Color.appPrimary)
|
||||||
|
}
|
||||||
|
.opacity(0.6)
|
||||||
|
.padding(.bottom, 40)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.onAppear {
|
||||||
|
isAnimating = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Organic Toolbar Add Button
|
||||||
|
|
||||||
|
private struct OrganicToolbarAddButton: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||||||
clipShape(RoundedCorner(radius: radius, corners: corners))
|
clipShape(RoundedCorner(radius: radius, corners: corners))
|
||||||
@@ -393,7 +451,7 @@ extension View {
|
|||||||
struct RoundedCorner: Shape {
|
struct RoundedCorner: Shape {
|
||||||
var radius: CGFloat = .infinity
|
var radius: CGFloat = .infinity
|
||||||
var corners: UIRectCorner = .allCorners
|
var corners: UIRectCorner = .allCorners
|
||||||
|
|
||||||
func path(in rect: CGRect) -> Path {
|
func path(in rect: CGRect) -> Path {
|
||||||
let path = UIBezierPath(
|
let path = UIBezierPath(
|
||||||
roundedRect: rect,
|
roundedRect: rect,
|
||||||
@@ -411,7 +469,6 @@ struct RoundedCorner: Shape {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Array where Element == ResidenceResponse {
|
extension Array where Element == ResidenceResponse {
|
||||||
/// Returns the array as-is (for API compatibility)
|
|
||||||
func toResidences() -> [ResidenceResponse] {
|
func toResidences() -> [ResidenceResponse] {
|
||||||
return self
|
return self
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ struct CompleteTaskView: View {
|
|||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.appBackgroundPrimary)
|
.background(WarmGradientBackground())
|
||||||
.navigationTitle(L10n.Tasks.completeTask)
|
.navigationTitle(L10n.Tasks.completeTask)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -389,30 +389,34 @@ struct ContractorPickerView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(L10n.Tasks.noneManual)
|
Text(L10n.Tasks.noneManual)
|
||||||
.foregroundStyle(.primary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
Text(L10n.Tasks.enterManually)
|
Text(L10n.Tasks.enterManually)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if selectedContractor == nil {
|
if selectedContractor == nil {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.foregroundStyle(Color.appPrimary)
|
.foregroundColor(Color.appPrimary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listRowBackground(Color.appBackgroundSecondary)
|
||||||
|
|
||||||
// Contractors list
|
// Contractors list
|
||||||
if contractorViewModel.isLoading {
|
if contractorViewModel.isLoading {
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
.tint(Color.appPrimary)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.listRowBackground(Color.appBackgroundSecondary)
|
||||||
} else if let errorMessage = contractorViewModel.errorMessage {
|
} else if let errorMessage = contractorViewModel.errorMessage {
|
||||||
Text(errorMessage)
|
Text(errorMessage)
|
||||||
.foregroundStyle(Color.appError)
|
.foregroundColor(Color.appError)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
.listRowBackground(Color.appBackgroundSecondary)
|
||||||
} else {
|
} else {
|
||||||
ForEach(contractorViewModel.contractors, id: \.id) { contractor in
|
ForEach(contractorViewModel.contractors, id: \.id) { contractor in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@@ -422,12 +426,12 @@ struct ContractorPickerView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(contractor.name)
|
Text(contractor.name)
|
||||||
.foregroundStyle(.primary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
if let company = contractor.company {
|
if let company = contractor.company {
|
||||||
Text(company)
|
Text(company)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let firstSpecialty = contractor.specialties.first {
|
if let firstSpecialty = contractor.specialties.first {
|
||||||
@@ -437,7 +441,7 @@ struct ContractorPickerView: View {
|
|||||||
Text(firstSpecialty.name)
|
Text(firstSpecialty.name)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
}
|
}
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundColor(Color.appTextSecondary.opacity(0.7))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,13 +449,17 @@ struct ContractorPickerView: View {
|
|||||||
|
|
||||||
if selectedContractor?.id == contractor.id {
|
if selectedContractor?.id == contractor.id {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.foregroundStyle(Color.appPrimary)
|
.foregroundColor(Color.appPrimary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listRowBackground(Color.appBackgroundSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(WarmGradientBackground())
|
||||||
.navigationTitle(L10n.Tasks.selectContractor)
|
.navigationTitle(L10n.Tasks.selectContractor)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ struct TaskSuggestionsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.appBackgroundSecondary)
|
.background(Color.appBackgroundSecondary)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
.shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2)
|
.naturalShadow(.medium)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func categoryColor(for categoryName: String) -> Color {
|
private func categoryColor(for categoryName: String) -> Color {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ struct TaskTemplatesBrowserView: View {
|
|||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.appBackgroundPrimary)
|
.background(WarmGradientBackground())
|
||||||
.searchable(text: $searchText, prompt: "Search templates...")
|
.searchable(text: $searchText, prompt: "Search templates...")
|
||||||
.navigationTitle("Task Templates")
|
.navigationTitle("Task Templates")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
|||||||
@@ -9,121 +9,165 @@ struct VerifyEmailView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.appBackgroundPrimary
|
WarmGradientBackground()
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
ScrollView {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: OrganicSpacing.spacious) {
|
||||||
Spacer().frame(height: 20)
|
Spacer()
|
||||||
|
.frame(height: OrganicSpacing.comfortable)
|
||||||
|
|
||||||
// Header
|
// Hero Section
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
Image(systemName: "envelope.badge.shield.half.filled")
|
ZStack {
|
||||||
.font(.system(size: 60))
|
Circle()
|
||||||
.foregroundStyle(Color.appPrimary.gradient)
|
.fill(
|
||||||
.padding(.bottom, 8)
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(0.15),
|
||||||
|
Color.appPrimary.opacity(0.05),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 60
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
Text(L10n.Auth.verifyYourEmail)
|
Image(systemName: "envelope.badge.shield.half.filled")
|
||||||
.font(.title)
|
.font(.system(size: 48, weight: .medium))
|
||||||
.fontWeight(.bold)
|
.foregroundColor(Color.appPrimary)
|
||||||
.foregroundColor(Color.appTextPrimary)
|
}
|
||||||
|
|
||||||
Text(L10n.Auth.verifyMustVerify)
|
VStack(spacing: 8) {
|
||||||
.font(.subheadline)
|
Text(L10n.Auth.verifyYourEmail)
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
.multilineTextAlignment(.center)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
.padding(.horizontal)
|
|
||||||
|
Text(L10n.Auth.verifyMustVerify)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info Card
|
// Form Card
|
||||||
GroupBox {
|
VStack(spacing: 20) {
|
||||||
|
// Info Banner
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "exclamationmark.shield.fill")
|
ZStack {
|
||||||
.foregroundColor(Color.appAccent)
|
Circle()
|
||||||
.font(.title2)
|
.fill(Color.appAccent.opacity(0.1))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
Image(systemName: "exclamationmark.shield.fill")
|
||||||
|
.font(.system(size: 18, weight: .medium))
|
||||||
|
.foregroundColor(Color.appAccent)
|
||||||
|
}
|
||||||
|
|
||||||
Text(L10n.Auth.verifyCheckInbox)
|
Text(L10n.Auth.verifyCheckInbox)
|
||||||
.font(.subheadline)
|
.font(.system(size: 14, weight: .semibold))
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
.fontWeight(.semibold)
|
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(16)
|
||||||
}
|
.background(Color.appAccent.opacity(0.08))
|
||||||
.padding(.horizontal)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
|
||||||
// Code Input
|
// Code Input
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(L10n.Auth.verifyCodeLabel)
|
Text(L10n.Auth.verifyCodeLabel.uppercased())
|
||||||
.font(.headline)
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
.padding(.horizontal)
|
.tracking(1.2)
|
||||||
|
|
||||||
TextField("000000", text: $viewModel.code)
|
TextField("000000", text: $viewModel.code)
|
||||||
.font(.system(size: 32, weight: .semibold, design: .rounded))
|
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
.textFieldStyle(.roundedBorder)
|
.focused($isFocused)
|
||||||
.frame(height: 60)
|
.keyboardDismissToolbar()
|
||||||
.padding(.horizontal)
|
.padding(20)
|
||||||
.focused($isFocused)
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.verificationCodeField)
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.keyboardDismissToolbar()
|
.overlay(
|
||||||
.onChange(of: viewModel.code) { _, newValue in
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
// Limit to 6 digits
|
.stroke(isFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
||||||
if newValue.count > 6 {
|
)
|
||||||
viewModel.code = String(newValue.prefix(6))
|
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.verificationCodeField)
|
||||||
|
.onChange(of: viewModel.code) { _, newValue in
|
||||||
|
if newValue.count > 6 {
|
||||||
|
viewModel.code = String(newValue.prefix(6))
|
||||||
|
}
|
||||||
|
viewModel.code = newValue.filter { $0.isNumber }
|
||||||
}
|
}
|
||||||
// Only allow numbers
|
|
||||||
viewModel.code = newValue.filter { $0.isNumber }
|
Text(L10n.Auth.verifyCodeMustBe6)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Message
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.appError.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
Text(L10n.Auth.verifyCodeMustBe6)
|
// Verify Button
|
||||||
.font(.caption)
|
Button(action: {
|
||||||
.foregroundColor(Color.appTextSecondary)
|
viewModel.verifyEmail()
|
||||||
.padding(.horizontal)
|
}) {
|
||||||
}
|
HStack(spacing: 8) {
|
||||||
|
if viewModel.isLoading {
|
||||||
// Error Message
|
ProgressView()
|
||||||
if let errorMessage = viewModel.errorMessage {
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
ErrorMessageView(message: errorMessage, onDismiss: viewModel.clearError)
|
} else {
|
||||||
.padding(.horizontal)
|
Image(systemName: "checkmark.shield.fill")
|
||||||
}
|
}
|
||||||
|
Text(viewModel.isLoading ? "Verifying..." : L10n.Auth.verifyEmailButton)
|
||||||
// Verify Button
|
.font(.headline)
|
||||||
Button(action: {
|
|
||||||
viewModel.verifyEmail()
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
if viewModel.isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
||||||
} else {
|
|
||||||
Image(systemName: "checkmark.shield.fill")
|
|
||||||
Text(L10n.Auth.verifyEmailButton)
|
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.background(
|
||||||
|
viewModel.code.count == 6 && !viewModel.isLoading
|
||||||
|
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
|
: AnyShapeStyle(Color.appTextSecondary)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.shadow(
|
||||||
|
color: viewModel.code.count == 6 && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
|
||||||
|
radius: 10,
|
||||||
|
y: 5
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
|
||||||
.frame(height: 50)
|
|
||||||
.background(
|
// Help Text
|
||||||
viewModel.code.count == 6 && !viewModel.isLoading
|
Text(L10n.Auth.verifyHelpText)
|
||||||
? Color.appPrimary
|
.font(.system(size: 12, weight: .medium))
|
||||||
: Color.gray.opacity(0.3)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
}
|
||||||
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
|
.padding(OrganicSpacing.cozy)
|
||||||
.padding(.horizontal)
|
.background(OrganicVerifyEmailBackground())
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
|
||||||
|
.naturalShadow(.pronounced)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
Spacer().frame(height: 20)
|
Spacer()
|
||||||
|
|
||||||
// Help Text
|
|
||||||
Text(L10n.Auth.verifyHelpText)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(Color.appTextSecondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.horizontal, 32)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,12 +175,17 @@ struct VerifyEmailView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(action: onLogout) {
|
Button(action: onLogout) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||||
.font(.system(size: 16))
|
.font(.system(size: 14, weight: .medium))
|
||||||
Text(L10n.Auth.logout)
|
Text(L10n.Auth.logout)
|
||||||
.font(.subheadline)
|
.font(.system(size: 14, weight: .semibold))
|
||||||
}
|
}
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.appBackgroundSecondary.opacity(0.8))
|
||||||
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,10 +197,38 @@ struct VerifyEmailView: View {
|
|||||||
onVerifySuccess()
|
onVerifySuccess()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.handleErrors(
|
}
|
||||||
error: viewModel.errorMessage,
|
}
|
||||||
onRetry: { viewModel.verifyEmail() }
|
}
|
||||||
)
|
|
||||||
|
// MARK: - Background
|
||||||
|
|
||||||
|
private struct OrganicVerifyEmailBackground: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundSecondary
|
||||||
|
|
||||||
|
GeometryReader { geo in
|
||||||
|
OrganicBlobShape(variation: 0)
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
|
||||||
|
Color.appPrimary.opacity(0.01)
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: geo.size.width * 0.5
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.5)
|
||||||
|
.offset(x: geo.size.width * 0.4, y: -geo.size.height * 0.1)
|
||||||
|
.blur(radius: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
GrainTexture(opacity: 0.015)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user