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(.horizontal, 16)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
|
||||||
// Active Filters
|
// Active Filters — hidden when the list is empty so the empty
|
||||||
if showFavoritesOnly || selectedSpecialty != nil {
|
// placeholder centers in the full screen rather than being
|
||||||
|
// offset by this header.
|
||||||
|
if (showFavoritesOnly || selectedSpecialty != nil) && !filteredContractors.isEmpty {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
if showFavoritesOnly {
|
if showFavoritesOnly {
|
||||||
@@ -85,8 +87,11 @@ struct ContractorsListView: View {
|
|||||||
},
|
},
|
||||||
emptyContent: {
|
emptyContent: {
|
||||||
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
|
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
|
||||||
OrganicEmptyContractorsView(
|
let hasFilters = showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
|
||||||
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 {
|
} else {
|
||||||
UpgradeFeatureView(
|
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 {
|
#Preview {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
ContractorsListView()
|
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
|
// MARK: - List Async Content View
|
||||||
|
|
||||||
/// Specialized async content view for lists with pull-to-refresh support
|
/// 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
|
GeometryReader { geometry in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
DefaultErrorView(message: errorMessage, onRetry: onRetry)
|
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 {
|
} 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
|
GeometryReader { geometry in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
emptyContent()
|
emptyContent()
|
||||||
.frame(maxWidth: .infinity, minHeight: geometry.size.height * 0.6)
|
.frame(maxWidth: .infinity, minHeight: geometry.size.height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -280,7 +229,7 @@ struct AsyncContentView_Previews: PreviewProvider {
|
|||||||
)
|
)
|
||||||
.previewDisplayName("Error")
|
.previewDisplayName("Error")
|
||||||
|
|
||||||
AsyncEmptyStateView(
|
OrganicEmptyScreen(
|
||||||
icon: "tray",
|
icon: "tray",
|
||||||
title: "No Items",
|
title: "No Items",
|
||||||
subtitle: "Add your first item to get started",
|
subtitle: "Add your first item to get started",
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ struct DocumentsTabContent: View {
|
|||||||
},
|
},
|
||||||
emptyContent: {
|
emptyContent: {
|
||||||
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: filteredDocuments.count, limitKey: "documents") {
|
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: filteredDocuments.count, limitKey: "documents") {
|
||||||
EmptyStateView(
|
OrganicEmptyScreen(
|
||||||
icon: "doc",
|
icon: "doc",
|
||||||
title: L10n.Documents.noDocumentsFound,
|
title: L10n.Documents.noDocumentsFound,
|
||||||
message: L10n.Documents.noDocumentsMessage
|
subtitle: L10n.Documents.noDocumentsMessage
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
UpgradeFeatureView(
|
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: {
|
emptyContent: {
|
||||||
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: filteredWarranties.count, limitKey: "documents") {
|
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: filteredWarranties.count, limitKey: "documents") {
|
||||||
EmptyStateView(
|
OrganicEmptyScreen(
|
||||||
icon: "doc.text.viewfinder",
|
icon: "doc.text.viewfinder",
|
||||||
title: L10n.Documents.noWarrantiesFound,
|
title: L10n.Documents.noWarrantiesFound,
|
||||||
message: L10n.Documents.noWarrantiesMessage
|
subtitle: L10n.Documents.noWarrantiesMessage
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
UpgradeFeatureView(
|
UpgradeFeatureView(
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ struct ResidencesListView: View {
|
|||||||
ResidencesContent(residences: residences)
|
ResidencesContent(residences: residences)
|
||||||
},
|
},
|
||||||
emptyContent: {
|
emptyContent: {
|
||||||
OrganicEmptyResidencesView()
|
OrganicEmptyScreen(
|
||||||
|
imageName: "outline",
|
||||||
|
title: "Welcome to Your Space",
|
||||||
|
subtitle: "Tap the + icon in the top right\nto add your first property"
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onRefresh: {
|
onRefresh: {
|
||||||
viewModel.loadMyResidences(forceRefresh: true)
|
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 {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ResidencesListView()
|
ResidencesListView()
|
||||||
|
|||||||
@@ -170,3 +170,129 @@ struct ListEmptyState: View {
|
|||||||
.accessibilityElement(children: .combine)
|
.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 {
|
} else if let tasksResponse = tasksResponse {
|
||||||
if hasNoTasks {
|
if hasNoTasks {
|
||||||
OrganicEmptyTasksView(
|
let hasResidences = !(residenceViewModel.myResidences?.residences.isEmpty ?? true)
|
||||||
totalTaskCount: totalTaskCount,
|
if hasResidences {
|
||||||
hasResidences: !(residenceViewModel.myResidences?.residences.isEmpty ?? true),
|
OrganicEmptyScreen(
|
||||||
subscriptionCache: subscriptionCache,
|
icon: "checklist",
|
||||||
showingUpgradePrompt: $showingUpgradePrompt,
|
title: L10n.Tasks.noTasksYet,
|
||||||
showAddTask: $showAddTask
|
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 {
|
} else {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
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
|
// MARK: - Organic Toolbar Add Button
|
||||||
|
|
||||||
private struct OrganicToolbarAddButton: View {
|
private struct OrganicToolbarAddButton: View {
|
||||||
|
|||||||
Reference in New Issue
Block a user