Files
honeyDueKMP/iosApp/iosApp/Core/AsyncContentView.swift
Trey t 98dbacdea0 Add task completion animations, subscription trials, and quiet debug console
- 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>
2026-03-05 11:35:08 -06:00

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