- Completion animations: play user-selected animation on task card after completing, with DataManager guard to prevent race condition during animation playback. Works in both AllTasksView and ResidenceDetailView. Animation preference persisted via @AppStorage and configurable from Settings. - Subscription: add trial fields (trialStart, trialEnd, trialActive) and subscriptionSource to model, cross-platform purchase guard, trial banner in upgrade prompt, and platform-aware subscription management in profile. - Analytics: disable PostHog SDK debug logging and remove console print statements to reduce debug console noise. - Documents: remove redundant nested do-catch blocks in ViewModel wrapper. - Widgets: add debounced timeline reloads and thread-safe file I/O queue. - Onboarding: fix animation leak on disappear, remove unused state vars. - Remove unused files (ContentView, StateFlowExtensions, CustomView). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
298 lines
8.6 KiB
Swift
298 lines
8.6 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Async Content View
|
|
|
|
/// A reusable view for handling async content states (loading, success, error)
|
|
struct AsyncContentView<T, Content: View, Loading: View, Error: View>: View {
|
|
let state: ViewState<T>
|
|
let content: (T) -> Content
|
|
let loading: () -> Loading
|
|
let error: (String, @escaping () -> Void) -> Error
|
|
let onRetry: () -> Void
|
|
|
|
init(
|
|
state: ViewState<T>,
|
|
@ViewBuilder content: @escaping (T) -> Content,
|
|
@ViewBuilder loading: @escaping () -> Loading,
|
|
@ViewBuilder error: @escaping (String, @escaping () -> Void) -> Error,
|
|
onRetry: @escaping () -> Void
|
|
) {
|
|
self.state = state
|
|
self.content = content
|
|
self.loading = loading
|
|
self.error = error
|
|
self.onRetry = onRetry
|
|
}
|
|
|
|
var body: some View {
|
|
switch state {
|
|
case .idle:
|
|
EmptyView()
|
|
case .loading:
|
|
loading()
|
|
case .loaded(let data):
|
|
content(data)
|
|
case .error(let message):
|
|
error(message, onRetry)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Convenience Initializers
|
|
|
|
extension AsyncContentView where Loading == DefaultLoadingView {
|
|
/// Initialize with default loading view
|
|
init(
|
|
state: ViewState<T>,
|
|
@ViewBuilder content: @escaping (T) -> Content,
|
|
@ViewBuilder error: @escaping (String, @escaping () -> Void) -> Error,
|
|
onRetry: @escaping () -> Void
|
|
) {
|
|
self.state = state
|
|
self.content = content
|
|
self.loading = { DefaultLoadingView() }
|
|
self.error = error
|
|
self.onRetry = onRetry
|
|
}
|
|
}
|
|
|
|
extension AsyncContentView where Error == DefaultErrorView {
|
|
/// Initialize with default error view
|
|
init(
|
|
state: ViewState<T>,
|
|
@ViewBuilder content: @escaping (T) -> Content,
|
|
@ViewBuilder loading: @escaping () -> Loading,
|
|
onRetry: @escaping () -> Void
|
|
) {
|
|
self.state = state
|
|
self.content = content
|
|
self.loading = loading
|
|
self.error = { message, retry in DefaultErrorView(message: message, onRetry: retry) }
|
|
self.onRetry = onRetry
|
|
}
|
|
}
|
|
|
|
extension AsyncContentView where Loading == DefaultLoadingView, Error == DefaultErrorView {
|
|
/// Initialize with default loading and error views
|
|
init(
|
|
state: ViewState<T>,
|
|
@ViewBuilder content: @escaping (T) -> Content,
|
|
onRetry: @escaping () -> Void
|
|
) {
|
|
self.state = state
|
|
self.content = content
|
|
self.loading = { DefaultLoadingView() }
|
|
self.error = { message, retry in DefaultErrorView(message: message, onRetry: retry) }
|
|
self.onRetry = onRetry
|
|
}
|
|
}
|
|
|
|
// MARK: - Default Loading View
|
|
|
|
struct DefaultLoadingView: View {
|
|
var body: some View {
|
|
VStack(spacing: 12) {
|
|
ProgressView()
|
|
.progressViewStyle(CircularProgressViewStyle())
|
|
.scaleEffect(1.2)
|
|
|
|
Text("Loading...")
|
|
.font(.subheadline)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
// MARK: - Default Error View
|
|
|
|
struct DefaultErrorView: View {
|
|
let message: String
|
|
let onRetry: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.system(size: 48))
|
|
.foregroundColor(Color.appError)
|
|
|
|
Text("Something went wrong")
|
|
.font(.headline)
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
Text(message)
|
|
.font(.subheadline)
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
|
|
Button(action: onRetry) {
|
|
Label("Try Again", systemImage: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(Color.appPrimary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
// 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
|
|
struct ListAsyncContentView<T, Content: View, EmptyContent: View>: View {
|
|
let items: [T]
|
|
let isLoading: Bool
|
|
let errorMessage: String?
|
|
let content: ([T]) -> Content
|
|
let emptyContent: () -> EmptyContent
|
|
let onRefresh: () -> Void
|
|
let onRetry: () -> Void
|
|
|
|
init(
|
|
items: [T],
|
|
isLoading: Bool,
|
|
errorMessage: String?,
|
|
@ViewBuilder content: @escaping ([T]) -> Content,
|
|
@ViewBuilder emptyContent: @escaping () -> EmptyContent,
|
|
onRefresh: @escaping () -> Void,
|
|
onRetry: @escaping () -> Void
|
|
) {
|
|
self.items = items
|
|
self.isLoading = isLoading
|
|
self.errorMessage = errorMessage
|
|
self.content = content
|
|
self.emptyContent = emptyContent
|
|
self.onRefresh = onRefresh
|
|
self.onRetry = onRetry
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let errorMessage = errorMessage, items.isEmpty {
|
|
// Wrap in ScrollView for pull-to-refresh support
|
|
GeometryReader { geometry in
|
|
ScrollView {
|
|
DefaultErrorView(message: errorMessage, onRetry: onRetry)
|
|
.frame(maxWidth: .infinity, minHeight: geometry.size.height * 0.6)
|
|
}
|
|
}
|
|
} else if items.isEmpty && !isLoading {
|
|
// Wrap in ScrollView for pull-to-refresh support
|
|
GeometryReader { geometry in
|
|
ScrollView {
|
|
emptyContent()
|
|
.frame(maxWidth: .infinity, minHeight: geometry.size.height * 0.6)
|
|
}
|
|
}
|
|
} else {
|
|
content(items)
|
|
}
|
|
}
|
|
.overlay {
|
|
if isLoading && items.isEmpty {
|
|
DefaultLoadingView()
|
|
}
|
|
}
|
|
.refreshable {
|
|
await withCheckedContinuation { continuation in
|
|
onRefresh()
|
|
continuation.resume()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#if DEBUG
|
|
struct AsyncContentView_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
Group {
|
|
AsyncContentView(
|
|
state: ViewState<String>.loading,
|
|
content: { data in Text(data) },
|
|
onRetry: {}
|
|
)
|
|
.previewDisplayName("Loading")
|
|
|
|
AsyncContentView(
|
|
state: ViewState<String>.loaded("Hello, World!"),
|
|
content: { data in Text(data) },
|
|
onRetry: {}
|
|
)
|
|
.previewDisplayName("Loaded")
|
|
|
|
AsyncContentView(
|
|
state: ViewState<String>.error("Network connection failed"),
|
|
content: { data in Text(data) },
|
|
onRetry: {}
|
|
)
|
|
.previewDisplayName("Error")
|
|
|
|
AsyncEmptyStateView(
|
|
icon: "tray",
|
|
title: "No Items",
|
|
subtitle: "Add your first item to get started",
|
|
actionLabel: "Add Item",
|
|
action: {}
|
|
)
|
|
.previewDisplayName("Empty State")
|
|
}
|
|
}
|
|
}
|
|
#endif
|