Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,391 @@
//
// ToastManager.swift
// Yattee
//
// Manages toast notification lifecycle and display queue.
//
import Foundation
import SwiftUI
/// Manages toast notifications across the app.
@MainActor
@Observable
final class ToastManager {
// MARK: - State
/// Active toasts, sorted by priority (highest first)
private(set) var activeToasts: [Toast] = []
/// Auto-dismiss tasks keyed by toast ID
private var dismissTasks: [UUID: Task<Void, Never>] = [:]
/// Reference to navigation coordinator for checking player visibility.
private weak var navigationCoordinator: NavigationCoordinator?
/// Task for observing player visibility changes.
private var playerVisibilityObserverTask: Task<Void, Never>?
// MARK: - Configuration
/// Set the navigation coordinator reference.
func setNavigationCoordinator(_ coordinator: NavigationCoordinator) {
self.navigationCoordinator = coordinator
startPlayerVisibilityObserver()
}
// MARK: - Scope Resolution
/// Determines whether the player is currently visible (expanded sheet on iOS, window on macOS).
private var isPlayerVisible: Bool {
#if os(macOS)
return ExpandedPlayerWindowManager.shared.isPresented
#else
return navigationCoordinator?.isPlayerExpanded ?? false
#endif
}
/// Resolves the active scope for a toast based on current UI state.
/// If player is visible and the toast supports player scope, use player.
/// Otherwise use main scope if supported.
private func resolveActiveScope(for scopes: Set<ToastScope>) -> ToastScope {
if isPlayerVisible && scopes.contains(.player) {
return .player
}
if scopes.contains(.main) {
return .main
}
// Fallback to first available scope
return scopes.first ?? .main
}
/// Re-evaluates and updates the active scope for all toasts based on current player visibility.
/// This allows toasts to migrate from .main to .player when the player becomes visible.
private func updateToastScopes() {
guard !self.activeToasts.isEmpty else { return }
LoggingService.shared.info("🔄 Updating toast scopes - \(self.activeToasts.count) active toast(s), player visible: \(self.isPlayerVisible)", category: .general)
var updated = false
for index in self.activeToasts.indices {
let toast = self.activeToasts[index]
let newActiveScope = resolveActiveScope(for: toast.scopes)
if toast.activeScope != newActiveScope {
LoggingService.shared.info("📱 Migrating toast '\(toast.title)' from \(String(describing: toast.activeScope)) to \(String(describing: newActiveScope))", category: .general)
self.activeToasts[index].activeScope = newActiveScope
updated = true
}
}
// Trigger UI update if any scopes changed
if updated {
LoggingService.shared.info("✅ Toast scope migration complete - UI will refresh", category: .general)
// The @Observable macro will handle change notifications automatically
// Just need to ensure the array mutation is detected
self.activeToasts = self.activeToasts
}
}
/// Start observing player visibility changes to migrate toasts between scopes.
private func startPlayerVisibilityObserver() {
playerVisibilityObserverTask?.cancel()
playerVisibilityObserverTask = Task { [weak self] in
guard let self else { return }
var lastPlayerVisible = self.isPlayerVisible
while !Task.isCancelled {
// Wait for navigationCoordinator.isPlayerExpanded to change
// (On macOS, ExpandedPlayerWindowManager.isPresented is updated when window shows,
// but we track the NavigationCoordinator state which triggers the window)
if let coordinator = self.navigationCoordinator {
_ = withObservationTracking {
coordinator.isPlayerExpanded
} onChange: { }
}
try? await Task.sleep(for: .milliseconds(100))
guard !Task.isCancelled else { break }
let currentPlayerVisible = self.isPlayerVisible
if currentPlayerVisible != lastPlayerVisible {
LoggingService.shared.info("Player visibility changed: \(lastPlayerVisible) -> \(currentPlayerVisible)", category: .general)
lastPlayerVisible = currentPlayerVisible
self.updateToastScopes()
}
}
}
}
// MARK: - Filtered Access
/// Returns toasts for a specific scope (filtered by activeScope).
func toasts(for scope: ToastScope) -> [Toast] {
activeToasts.filter { $0.activeScope == scope }
}
// MARK: - Public API
/// Show a toast notification.
/// The toast will be displayed in the most appropriate scope based on current UI state.
/// If a toast with the same category exists in any scope, it will be replaced.
/// - Parameters:
/// - scopes: The scopes where this toast can be displayed (default: main and player)
/// - category: The category of the toast (affects replacement and priority)
/// - title: The title to display
/// - subtitle: Optional subtitle for additional context
/// - icon: Optional SF Symbol name
/// - iconColor: Optional icon color
/// - action: Optional action button
/// - autoDismissDelay: Time before auto-dismiss (default: 3s)
/// - isPersistent: If true, won't auto-dismiss
/// - Returns: The ID of the shown toast
@discardableResult
func show(
scopes: Set<ToastScope> = [.main, .player],
category: ToastCategory,
title: String,
subtitle: String? = nil,
icon: String? = nil,
iconColor: Color? = nil,
action: ToastAction? = nil,
autoDismissDelay: TimeInterval = 3.0,
isPersistent: Bool = false
) -> UUID {
// Resolve the active scope based on current UI state
let activeScope = resolveActiveScope(for: scopes)
let toast = Toast(
scopes: scopes,
activeScope: activeScope,
category: category,
title: title,
subtitle: subtitle,
icon: icon,
iconColor: iconColor,
action: action,
autoDismissDelay: autoDismissDelay,
isPersistent: isPersistent
)
// Cancel and remove existing toast with same category (in any scope)
if let existingToast = activeToasts.first(where: { $0.category == category }) {
LoggingService.shared.info("Replacing toast for category: \(String(describing: category))", category: .general)
dismissInternal(id: existingToast.id, animated: false)
}
// Add new toast and sort by priority
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
activeToasts.append(toast)
activeToasts.sort { $0.category.priority > $1.category.priority }
}
let scopesStr = scopes.map { String(describing: $0) }.joined(separator: ", ")
let playerVisible = self.isPlayerVisible
LoggingService.shared.info("📢 Showing toast: '\(title)' in scope: \(String(describing: activeScope)) (supports: [\(scopesStr)]), persistent: \(isPersistent), player visible: \(playerVisible)", category: .general)
// Schedule auto-dismiss
// For persistent toasts, autoDismissDelay acts as a safety timeout to prevent stuck toasts
if !isPersistent || autoDismissDelay > 0 {
scheduleAutoDismiss(for: toast)
}
return toast.id
}
/// Update an existing toast's title/subtitle and icon, then schedule auto-dismiss.
/// Used for persistent toasts that need to show a final state before dismissing.
func update(
id: UUID,
title: String,
subtitle: String? = nil,
icon: String? = nil,
iconColor: Color? = nil,
autoDismissDelay: TimeInterval = 2.0
) {
LoggingService.shared.info("ToastManager.update called for id: \(id), title: \(title)", category: .general)
LoggingService.shared.info("Active toasts: \(self.activeToasts.map { $0.id })", category: .general)
guard let index = activeToasts.firstIndex(where: { $0.id == id }) else {
LoggingService.shared.warning("Cannot update toast \(id) - not found in active toasts", category: .general)
return
}
LoggingService.shared.info("Found toast at index \(index), updating...", category: .general)
// Update the toast
withAnimation(.easeInOut(duration: 0.2)) {
activeToasts[index].title = title
activeToasts[index].subtitle = subtitle
activeToasts[index].icon = icon
activeToasts[index].iconColor = iconColor
}
LoggingService.shared.info("Updated toast: \(title), scheduling dismiss in \(autoDismissDelay) seconds", category: .general)
// Cancel any existing dismiss task and schedule new one
dismissTasks[id]?.cancel()
let task = Task { [weak self] in
try? await Task.sleep(for: .seconds(autoDismissDelay))
guard !Task.isCancelled else { return }
LoggingService.shared.info("Auto-dismissing toast after update: \(id)", category: .general)
self?.dismissInternal(id: id, animated: true)
}
dismissTasks[id] = task
}
// MARK: - Player Scope Convenience Methods
/// Show a loading toast (player-only scope, longer auto-dismiss delay).
@discardableResult
func showPlayerLoading(_ title: String, subtitle: String? = nil) -> UUID {
show(
scopes: [.player],
category: .loading,
title: title,
subtitle: subtitle,
icon: nil, // Will show ProgressView
iconColor: nil,
autoDismissDelay: 30.0 // Long timeout for loading states
)
}
/// Show a retry toast (player-only scope).
@discardableResult
func showPlayerRetry(_ title: String, subtitle: String? = nil) -> UUID {
show(
scopes: [.player],
category: .retry,
title: title,
subtitle: subtitle,
icon: "arrow.clockwise",
iconColor: .orange,
autoDismissDelay: 5.0
)
}
/// Show an error toast (player-only scope).
@discardableResult
func showPlayerError(_ title: String, subtitle: String? = nil) -> UUID {
show(
scopes: [.player],
category: .error,
title: title,
subtitle: subtitle,
icon: "exclamationmark.triangle.fill",
iconColor: .red,
autoDismissDelay: 5.0
)
}
// MARK: - General Convenience Methods
/// Show an error toast (shows in player if visible, otherwise main).
@discardableResult
func showError(_ title: String, subtitle: String? = nil) -> UUID {
show(
category: .error,
title: title,
subtitle: subtitle,
icon: "exclamationmark.triangle.fill",
iconColor: .red,
autoDismissDelay: 5.0
)
}
/// Show a success toast (shows in player if visible, otherwise main).
@discardableResult
func showSuccess(_ title: String, subtitle: String? = nil) -> UUID {
show(
category: .success,
title: title,
subtitle: subtitle,
icon: "checkmark.circle.fill",
iconColor: .green,
autoDismissDelay: 2.0
)
}
/// Show an info toast (shows in player if visible, otherwise main).
@discardableResult
func showInfo(_ title: String, subtitle: String? = nil) -> UUID {
show(
category: .info,
title: title,
subtitle: subtitle,
icon: "info.circle.fill",
iconColor: .blue,
autoDismissDelay: 3.0
)
}
// MARK: - Dismiss Methods
/// Dismiss a specific toast.
func dismiss(id: UUID, animated: Bool = true) {
dismissInternal(id: id, animated: animated)
}
/// Dismiss all toasts in a category (across all scopes).
func dismissCategory(_ category: ToastCategory) {
let toastsToDismiss = activeToasts.filter { $0.category == category }
for toast in toastsToDismiss {
dismissInternal(id: toast.id, animated: true)
}
}
/// Dismiss all toasts in a category for a specific active scope.
func dismissCategory(_ category: ToastCategory, scope: ToastScope) {
let toastsToDismiss = activeToasts.filter { $0.category == category && $0.activeScope == scope }
for toast in toastsToDismiss {
dismissInternal(id: toast.id, animated: true)
}
}
/// Dismiss all toasts in a specific active scope.
func dismissScope(_ scope: ToastScope) {
let toastsToDismiss = activeToasts.filter { $0.activeScope == scope }
for toast in toastsToDismiss {
dismissInternal(id: toast.id, animated: true)
}
}
/// Dismiss all toasts.
func dismissAll() {
for task in dismissTasks.values {
task.cancel()
}
dismissTasks.removeAll()
withAnimation(.easeInOut(duration: 0.25)) {
activeToasts.removeAll()
}
}
// MARK: - Private
private func dismissInternal(id: UUID, animated: Bool) {
dismissTasks[id]?.cancel()
dismissTasks.removeValue(forKey: id)
if animated {
withAnimation(.easeInOut(duration: 0.25)) {
activeToasts.removeAll { $0.id == id }
}
} else {
activeToasts.removeAll { $0.id == id }
}
LoggingService.shared.info("Dismissed toast: \(id)", category: .general)
}
private func scheduleAutoDismiss(for toast: Toast) {
let task = Task { [weak self] in
try? await Task.sleep(for: .seconds(toast.autoDismissDelay))
guard !Task.isCancelled else { return }
self?.dismissInternal(id: toast.id, animated: true)
}
dismissTasks[toast.id] = task
}
}