PadXcode-iPad/UI/ToastSystem.swift
2026-04-12 22:07:35 -07:00

77 lines
2.8 KiB
Swift

import SwiftUI
struct Toast: Identifiable {
enum Style {
case success, error, warning, info, git
var icon: String {
switch self {
case .success: return "checkmark.circle.fill"
case .error: return "xmark.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .info: return "info.circle.fill"
case .git: return "arrow.triangle.branch"
}
}
var color: Color {
switch self {
case .success: return .green; case .error: return .red
case .warning: return .orange; case .info: return Color.accentColor; case .git: return .purple
}
}
}
let id = UUID()
let message: String
let style: Style
let duration: TimeInterval
init(_ message: String, style: Style = .info, duration: TimeInterval = 3) {
self.message = message; self.style = style; self.duration = duration
}
}
@MainActor
final class ToastStore: ObservableObject {
static let shared = ToastStore()
@Published var toasts: [Toast] = []
func show(_ toast: Toast) {
toasts.append(toast)
Task {
try? await Task.sleep(for: .seconds(toast.duration))
toasts.removeAll { $0.id == toast.id }
}
}
func show(_ message: String, style: Toast.Style = .info, duration: TimeInterval = 3) {
show(Toast(message, style: style, duration: duration))
}
}
struct ToastOverlay: ViewModifier {
@ObservedObject var store: ToastStore
func body(content: Content) -> some View {
content.overlay(alignment: .bottom) {
VStack(spacing: 8) {
ForEach(store.toasts) { toast in
HStack(spacing: 10) {
Image(systemName: toast.style.icon).foregroundStyle(toast.style.color)
Text(toast.message).font(.subheadline).lineLimit(2)
Spacer()
Button { store.toasts.removeAll { $0.id == toast.id } }
label: { Image(systemName: "xmark").font(.caption.bold()).foregroundStyle(.secondary) }
.buttonStyle(.plain)
}
.padding(.horizontal, 14).padding(.vertical, 10)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4)
.padding(.horizontal, 16)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.padding(.bottom, 60)
.animation(.spring(response: 0.3), value: store.toasts.count)
}
}
}
extension View {
func toastOverlay() -> some View { modifier(ToastOverlay(store: ToastStore.shared)) }
}