iOS: unify empty states — one centered, leaf-decorated component
Android UI Tests / ui-tests (push) Has been cancelled
Android UI Tests / ui-tests (push) Has been cancelled
All screen-level empty states now use a single OrganicEmptyScreen that fills the screen and centers its icon/title/subtitle/action in the dead middle (both axes), with the three animated FloatingLeaf footer on every empty screen. - Add canonical OrganicEmptyScreen (Shared/Components/SharedEmptyStateView) - Fix ListAsyncContentView: empty/error content used minHeight 60% of the screen (placeholder sat in the top portion) → use full height so it centers dead-center regardless of headers - Hide Contractors' filter bar when the list is empty so the placeholder stays screen-centered - Route Properties / Tasks / Contractors / Documents / Warranties empties through OrganicEmptyScreen; preserve the Tasks empty's branching (no-residences vs add-task vs upgrade-prompt) - Remove the duplicate/dead empty components Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,8 +48,10 @@ struct ContractorsListView: View {
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
// Active Filters
|
||||
if showFavoritesOnly || selectedSpecialty != nil {
|
||||
// Active Filters — hidden when the list is empty so the empty
|
||||
// placeholder centers in the full screen rather than being
|
||||
// offset by this header.
|
||||
if (showFavoritesOnly || selectedSpecialty != nil) && !filteredContractors.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
if showFavoritesOnly {
|
||||
@@ -85,8 +87,11 @@ struct ContractorsListView: View {
|
||||
},
|
||||
emptyContent: {
|
||||
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
|
||||
OrganicEmptyContractorsView(
|
||||
hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
|
||||
let hasFilters = showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
|
||||
OrganicEmptyScreen(
|
||||
icon: "person.2.fill",
|
||||
title: hasFilters ? L10n.Contractors.emptyFiltered : L10n.Contractors.emptyTitle,
|
||||
subtitle: hasFilters ? "" : L10n.Contractors.emptyNoFilters
|
||||
)
|
||||
} else {
|
||||
UpgradeFeatureView(
|
||||
@@ -414,70 +419,6 @@ private struct OrganicToolbarButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Empty Contractors View
|
||||
|
||||
private struct OrganicEmptyContractorsView: View {
|
||||
let hasFilters: Bool
|
||||
@State private var isAnimating = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
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(
|
||||
isAnimating && !reduceMotion
|
||||
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
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
|
||||
}
|
||||
.onDisappear {
|
||||
isAnimating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
ContractorsListView()
|
||||
|
||||
@@ -137,59 +137,6 @@ struct DefaultErrorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Async Empty State View
|
||||
|
||||
struct AsyncEmptyStateView: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let actionLabel: String?
|
||||
let action: (() -> Void)?
|
||||
|
||||
init(
|
||||
icon: String,
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
actionLabel: String? = nil,
|
||||
action: (() -> Void)? = nil
|
||||
) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.actionLabel = actionLabel
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.5))
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
if let subtitle = subtitle {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
if let actionLabel = actionLabel, let action = action {
|
||||
Button(action: action) {
|
||||
Text(actionLabel)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List Async Content View
|
||||
|
||||
/// Specialized async content view for lists with pull-to-refresh support
|
||||
@@ -227,15 +174,17 @@ struct ListAsyncContentView<T, Content: View, EmptyContent: View>: View {
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
DefaultErrorView(message: errorMessage, onRetry: onRetry)
|
||||
.frame(maxWidth: .infinity, minHeight: geometry.size.height * 0.6)
|
||||
.frame(maxWidth: .infinity, minHeight: geometry.size.height)
|
||||
}
|
||||
}
|
||||
} else if items.isEmpty && !isLoading {
|
||||
// Wrap in ScrollView for pull-to-refresh support
|
||||
// Wrap in ScrollView for pull-to-refresh support. Use the FULL
|
||||
// height so the empty content centers in the dead middle of the
|
||||
// screen (not the top 60%).
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
emptyContent()
|
||||
.frame(maxWidth: .infinity, minHeight: geometry.size.height * 0.6)
|
||||
.frame(maxWidth: .infinity, minHeight: geometry.size.height)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -280,7 +229,7 @@ struct AsyncContentView_Previews: PreviewProvider {
|
||||
)
|
||||
.previewDisplayName("Error")
|
||||
|
||||
AsyncEmptyStateView(
|
||||
OrganicEmptyScreen(
|
||||
icon: "tray",
|
||||
title: "No Items",
|
||||
subtitle: "Add your first item to get started",
|
||||
|
||||
@@ -26,10 +26,10 @@ struct DocumentsTabContent: View {
|
||||
},
|
||||
emptyContent: {
|
||||
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: filteredDocuments.count, limitKey: "documents") {
|
||||
EmptyStateView(
|
||||
OrganicEmptyScreen(
|
||||
icon: "doc",
|
||||
title: L10n.Documents.noDocumentsFound,
|
||||
message: L10n.Documents.noDocumentsMessage
|
||||
subtitle: L10n.Documents.noDocumentsMessage
|
||||
)
|
||||
} else {
|
||||
UpgradeFeatureView(
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EmptyStateView: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: OrganicSpacing.cozy) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.08))
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 44, weight: .medium))
|
||||
.foregroundColor(Color.appPrimary.opacity(0.6))
|
||||
}
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text(message)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(OrganicSpacing.comfortable)
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,10 @@ struct WarrantiesTabContent: View {
|
||||
},
|
||||
emptyContent: {
|
||||
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: filteredWarranties.count, limitKey: "documents") {
|
||||
EmptyStateView(
|
||||
OrganicEmptyScreen(
|
||||
icon: "doc.text.viewfinder",
|
||||
title: L10n.Documents.noWarrantiesFound,
|
||||
message: L10n.Documents.noWarrantiesMessage
|
||||
subtitle: L10n.Documents.noWarrantiesMessage
|
||||
)
|
||||
} else {
|
||||
UpgradeFeatureView(
|
||||
|
||||
@@ -26,7 +26,11 @@ struct ResidencesListView: View {
|
||||
ResidencesContent(residences: residences)
|
||||
},
|
||||
emptyContent: {
|
||||
OrganicEmptyResidencesView()
|
||||
OrganicEmptyScreen(
|
||||
imageName: "outline",
|
||||
title: "Welcome to Your Space",
|
||||
subtitle: "Tap the + icon in the top right\nto add your first property"
|
||||
)
|
||||
},
|
||||
onRefresh: {
|
||||
viewModel.loadMyResidences(forceRefresh: true)
|
||||
@@ -248,97 +252,6 @@ private struct ResidencesContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Empty Residences View
|
||||
|
||||
private struct OrganicEmptyResidencesView: View {
|
||||
@State private var isAnimating = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
Spacer()
|
||||
|
||||
// Animated house illustration
|
||||
ZStack {
|
||||
// Background glow
|
||||
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(
|
||||
isAnimating && !reduceMotion
|
||||
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
value: isAnimating
|
||||
)
|
||||
|
||||
// House icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
Image("outline")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 48, height: 48)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.offset(y: isAnimating ? -2 : 2)
|
||||
.animation(
|
||||
isAnimating && !reduceMotion
|
||||
? Animation.easeInOut(duration: 2).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
value: isAnimating
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text("Welcome to Your Space")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text("Tap the + icon in the top right\nto add your first property")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Decorative footer elements
|
||||
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
|
||||
}
|
||||
.onDisappear {
|
||||
isAnimating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
ResidencesListView()
|
||||
|
||||
@@ -170,3 +170,129 @@ struct ListEmptyState: View {
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Canonical Full-Screen Empty State
|
||||
//
|
||||
// The single empty-state used by every screen. It fills its container and
|
||||
// places the icon + title + subtitle + optional action in the DEAD CENTER
|
||||
// (both axes), with the three animated FloatingLeaf as a bottom footer.
|
||||
// Center it on the screen by giving it the full screen area (e.g. as the
|
||||
// sole child of a screen-filling ZStack, or via a maxHeight: .infinity
|
||||
// container) so it is unaffected by any header/toolbar above it.
|
||||
struct OrganicEmptyScreen: View {
|
||||
/// SF Symbol name (used when `imageName` is nil).
|
||||
var icon: String? = nil
|
||||
/// Custom asset-catalog image (template-rendered); takes precedence over `icon`.
|
||||
var imageName: String? = nil
|
||||
let title: String
|
||||
let subtitle: String
|
||||
var actionLabel: String? = nil
|
||||
var action: (() -> Void)? = nil
|
||||
var accentColor: Color = Color.appPrimary
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var isAnimating = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Dead-centered placeholder content
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
illustration
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text(title)
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
|
||||
if let actionLabel = actionLabel, let action = action {
|
||||
Button(action: action) {
|
||||
Text(actionLabel)
|
||||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 14)
|
||||
.background(Capsule().fill(accentColor))
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement(children: .combine)
|
||||
|
||||
// Decorative three-leaf footer (consistent across every empty screen)
|
||||
VStack {
|
||||
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)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear { isAnimating = true }
|
||||
.onDisappear { isAnimating = false }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var illustration: some View {
|
||||
ZStack {
|
||||
// Background glow
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [accentColor.opacity(0.15), accentColor.opacity(0.05), Color.clear],
|
||||
center: .center, startRadius: 0, endRadius: 80
|
||||
)
|
||||
)
|
||||
.frame(width: 160, height: 160)
|
||||
.scaleEffect(isAnimating && !reduceMotion ? 1.08 : 1.0)
|
||||
.animation(
|
||||
isAnimating && !reduceMotion
|
||||
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
value: isAnimating
|
||||
)
|
||||
|
||||
// Icon disc
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(accentColor.opacity(0.1))
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
if let imageName = imageName {
|
||||
Image(imageName)
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 48, height: 48)
|
||||
.foregroundColor(accentColor)
|
||||
} else {
|
||||
Image(systemName: icon ?? "tray")
|
||||
.font(.system(size: 40, weight: .medium))
|
||||
.foregroundColor(accentColor)
|
||||
}
|
||||
}
|
||||
.offset(y: isAnimating && !reduceMotion ? -2 : 2)
|
||||
.animation(
|
||||
isAnimating && !reduceMotion
|
||||
? Animation.easeInOut(duration: 2).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
value: isAnimating
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,13 +171,31 @@ struct AllTasksView: View {
|
||||
}
|
||||
} else if let tasksResponse = tasksResponse {
|
||||
if hasNoTasks {
|
||||
OrganicEmptyTasksView(
|
||||
totalTaskCount: totalTaskCount,
|
||||
hasResidences: !(residenceViewModel.myResidences?.residences.isEmpty ?? true),
|
||||
subscriptionCache: subscriptionCache,
|
||||
showingUpgradePrompt: $showingUpgradePrompt,
|
||||
showAddTask: $showAddTask
|
||||
let hasResidences = !(residenceViewModel.myResidences?.residences.isEmpty ?? true)
|
||||
if hasResidences {
|
||||
OrganicEmptyScreen(
|
||||
icon: "checklist",
|
||||
title: L10n.Tasks.noTasksYet,
|
||||
subtitle: L10n.Tasks.createFirst,
|
||||
actionLabel: L10n.Tasks.addButton,
|
||||
action: {
|
||||
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
|
||||
showingUpgradePrompt = true
|
||||
} else {
|
||||
showAddTask = true
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// No residences: the original action button was disabled and
|
||||
// showed "Add a property first" guidance, so surface that copy
|
||||
// as the subtitle with no actionable button.
|
||||
OrganicEmptyScreen(
|
||||
icon: "checklist",
|
||||
title: L10n.Tasks.noTasksYet,
|
||||
subtitle: L10n.Tasks.addPropertyFirst
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
@@ -353,134 +371,6 @@ struct AllTasksView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
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(
|
||||
isAnimating && !reduceMotion
|
||||
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
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(
|
||||
isAnimating && !reduceMotion
|
||||
? Animation.easeInOut(duration: 2).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
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(hasResidences ? Color.appTextOnPrimary : Color.appTextSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(
|
||||
hasResidences
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
))
|
||||
: AnyShapeStyle(Color.appTextSecondary.opacity(0.3))
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.shadow(color: hasResidences ? Color.appPrimary.opacity(0.3) : Color.clear, 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
|
||||
}
|
||||
.onDisappear {
|
||||
isAnimating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Toolbar Add Button
|
||||
|
||||
private struct OrganicToolbarAddButton: View {
|
||||
|
||||
Reference in New Issue
Block a user