diff --git a/Bitkit/Components/ToastView.swift b/Bitkit/Components/ToastView.swift index c0c14936..bd625496 100644 --- a/Bitkit/Components/ToastView.swift +++ b/Bitkit/Components/ToastView.swift @@ -1,54 +1,89 @@ import SwiftUI -import UIKit struct ToastView: View { let toast: Toast let onDismiss: () -> Void + let onDragStart: () -> Void + let onDragEnd: () -> Void + + @State private var dragOffset: CGFloat = 0 + @State private var hasPausedAutoHide = false + private let dismissThreshold: CGFloat = 50 var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - VStack(alignment: .leading, spacing: 2) { - BodyMSBText(toast.title, textColor: accentColor) - if let description = toast.description { - CaptionText(description, textColor: .textPrimary) - } - } - Spacer() - if !toast.autoHide { - Button(action: onDismiss) { - Image("x-mark") - .foregroundColor(.white.opacity(0.6)) - } - } + VStack(alignment: .leading, spacing: 2) { + BodyMSBText(toast.title, textColor: accentColor) + + if let description = toast.description { + CaptionText(description, textColor: .textPrimary) } } - .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .background( - ZStack { - // Colored background - accentColor.opacity(0.7) - - // Black gradient overlay - LinearGradient( - gradient: Gradient(colors: [ - Color.black.opacity(0.6), - Color.black, - ]), - startPoint: .top, - endPoint: .bottom - ) - } - ) + .padding(16) + .background(accentColor.opacity(0.32)) .background(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(accentColor, lineWidth: 2) - ) - .cornerRadius(8) - .shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4) + .cornerRadius(16) + .shadow(color: .black.opacity(0.4), radius: 10, x: 0, y: 25) .accessibilityIdentifierIfPresent(toast.accessibilityIdentifier) + .overlay(alignment: .topTrailing) { + if !toast.autoHide { + Button(action: onDismiss) { + Image("x-mark") + .resizable() + .frame(width: 16, height: 16) + .foregroundColor(.textSecondary) + } + .accessibilityLabel("Dismiss toast") + .padding(16) + .contentShape(Rectangle()) + } + } + .offset(y: dragOffset) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + // Allow both upward and downward drag, but limit downward drag + let translation = value.translation.height + if translation < 0 { + // Upward drag - allow freely + dragOffset = translation + } else { + // Downward drag - apply resistance + dragOffset = translation * 0.08 + } + + // Pause auto-hide when drag starts (only once) + if abs(translation) > 5 && !hasPausedAutoHide { + hasPausedAutoHide = true + onDragStart() + } + } + .onEnded { value in + // Resume auto-hide when drag ends (if we paused it) + if hasPausedAutoHide { + hasPausedAutoHide = false + onDragEnd() + } + + // Dismiss if swiped up enough, otherwise snap back + if value.translation.height < -dismissThreshold { + withAnimation(.easeOut(duration: 0.3)) { + dragOffset = -200 + } + + // Dismiss after animation + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(0.3 * 1_000_000_000)) + onDismiss() + } + } else { + // Snap back to original position + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + dragOffset = 0 + } + } + } + ) } private var accentColor: Color { @@ -71,7 +106,10 @@ struct ToastView: View { autoHide: true, visibilityTime: 4.0, accessibilityIdentifier: nil - ), onDismiss: {} + ), + onDismiss: {}, + onDragStart: {}, + onDragEnd: {} ) .preferredColorScheme(.dark) } diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index 177aa315..d794d0ea 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -104,13 +104,15 @@ class ScannerManager: ObservableObject { app?.toast( type: .success, title: t("settings__es__server_updated_title"), - description: t("settings__es__server_updated_message", variables: ["host": result.host, "port": result.port]) + description: t("settings__es__server_updated_message", variables: ["host": result.host, "port": result.port]), + accessibilityIdentifier: "ElectrumUpdatedToast" ) } else { app?.toast( type: .warning, title: t("settings__es__error_peer"), - description: result.errorMessage ?? t("settings__es__server_error_description") + description: result.errorMessage ?? t("settings__es__server_error_description"), + accessibilityIdentifier: "ElectrumErrorToast" ) } } else { diff --git a/Bitkit/Managers/ToastWindowManager.swift b/Bitkit/Managers/ToastWindowManager.swift index 7bbbd245..71ed0299 100644 --- a/Bitkit/Managers/ToastWindowManager.swift +++ b/Bitkit/Managers/ToastWindowManager.swift @@ -8,7 +8,24 @@ class ToastWindowManager: ObservableObject { private var toastWindow: PassThroughWindow? private var toastHostingController: UIHostingController? + func updateToastFrame(globalFrame: CGRect) { + guard let window = toastWindow else { return } + // Convert from global (screen) coordinates to window coordinates + let windowOrigin = window.convert(CGPoint.zero, to: nil) + let convertedFrame = CGRect( + origin: CGPoint( + x: globalFrame.origin.x - windowOrigin.x, + y: globalFrame.origin.y - windowOrigin.y + ), + size: globalFrame.size + ) + window.toastFrame = convertedFrame + } + @Published var currentToast: Toast? + private var autoHideTask: Task? + private var autoHideStartTime: Date? + private var autoHideDuration: Double = 0 private init() { // Set up the window when the app starts @@ -19,7 +36,12 @@ class ToastWindowManager: ObservableObject { func showToast(_ toast: Toast) { // Dismiss any existing toast first - hideToast() + cancelAutoHide() + toastWindow?.hasToast = false + toastWindow?.toastFrame = .zero + + // Update window's toast state for hit testing + toastWindow?.hasToast = true // Show the toast with animation withAnimation(.easeInOut(duration: 0.4)) { @@ -28,18 +50,78 @@ class ToastWindowManager: ObservableObject { // Auto-hide if needed if toast.autoHide { - DispatchQueue.main.asyncAfter(deadline: .now() + toast.visibilityTime) { - withAnimation(.easeInOut(duration: 0.4)) { - self.currentToast = nil - } - } + scheduleAutoHide(after: toast.visibilityTime) } } func hideToast() { + cancelAutoHide() + toastWindow?.hasToast = false withAnimation(.easeInOut(duration: 0.4)) { currentToast = nil } + // Clear frame after animation completes to avoid race conditions during animation + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(0.4 * 1_000_000_000)) + self?.toastWindow?.toastFrame = .zero + } + } + + func pauseAutoHide() { + guard autoHideStartTime != nil else { return } // Already paused or no auto-hide + cancelAutoHide() + + // Calculate remaining time + if let startTime = autoHideStartTime { + let elapsed = Date().timeIntervalSince(startTime) + let remaining = max(0, autoHideDuration - elapsed) + autoHideDuration = remaining + autoHideStartTime = nil + } + } + + func resumeAutoHide() { + guard let toast = currentToast, toast.autoHide, autoHideStartTime == nil else { return } + // Use remaining time if available, otherwise use full duration + let delay = autoHideDuration > 0 ? autoHideDuration : toast.visibilityTime + scheduleAutoHide(after: delay) + } + + private func scheduleAutoHide(after delay: Double) { + cancelAutoHide() + autoHideStartTime = Date() + autoHideDuration = delay + + // Use Task instead of DispatchWorkItem for better SwiftUI integration + autoHideTask = Task { @MainActor [weak self] in + // Sleep for the delay duration + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + + // Check if task was cancelled or toast no longer exists + guard let self, !Task.isCancelled, currentToast != nil else { return } + + // Atomically update both hasToast and toastFrame + toastWindow?.hasToast = false + + withAnimation(.easeInOut(duration: 0.4)) { + self.currentToast = nil + } + + // Clear frame after animation completes to avoid race conditions during animation + try? await Task.sleep(nanoseconds: UInt64(0.4 * 1_000_000_000)) + guard !Task.isCancelled else { return } + toastWindow?.toastFrame = .zero + + autoHideStartTime = nil + autoHideDuration = 0 + } + } + + private func cancelAutoHide() { + autoHideTask?.cancel() + autoHideTask = nil + autoHideStartTime = nil + autoHideDuration = 0 } private func setupToastWindow() { @@ -65,12 +147,19 @@ class ToastWindowManager: ObservableObject { // Custom window that only intercepts touches on interactive elements class PassThroughWindow: UIWindow { + var hasToast: Bool = false + var toastFrame: CGRect = .zero + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let hitView = super.hitTest(point, with: event) - // If the hit view is the root view controller's view (the background), - // return nil to pass the touch through to the underlying window + // If the hit view is the root view controller's view (the background) if hitView == rootViewController?.view { + // If a toast is showing, check if touch is within the toast's frame + if hasToast && !toastFrame.isEmpty && toastFrame.contains(point) { + return rootViewController?.view + } + return nil } @@ -89,16 +178,44 @@ struct ToastWindowView: View { if let toast = toastManager.currentToast { VStack { - ToastView(toast: toast, onDismiss: toastManager.hideToast) - .padding(.horizontal) - .allowsHitTesting(true) // Only the toast itself can be tapped + ToastView( + toast: toast, + onDismiss: toastManager.hideToast, + onDragStart: toastManager.pauseAutoHide, + onDragEnd: toastManager.resumeAutoHide + ) + .padding(.horizontal) + .allowsHitTesting(true) // Only the toast itself can be tapped + .overlay( + GeometryReader { toastGeometry in + Color.clear + .preference( + key: ToastFramePreferenceKey.self, + value: toastGeometry.frame(in: .global) + ) + } + ) + Spacer() .allowsHitTesting(false) // Spacer doesn't intercept touches } + .id(toast.id) .transition(.move(edge: .top).combined(with: .opacity)) } } + .onPreferenceChange(ToastFramePreferenceKey.self) { frame in + // Only update if frame is not empty (valid frame from GeometryReader) + guard !frame.isEmpty else { return } + toastManager.updateToastFrame(globalFrame: frame) + } .animation(.easeInOut(duration: 0.4), value: toastManager.currentToast) .preferredColorScheme(.dark) // Force dark color scheme } } + +private struct ToastFramePreferenceKey: PreferenceKey { + static var defaultValue: CGRect = .zero + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + value = nextValue() + } +} diff --git a/Bitkit/Models/Toast.swift b/Bitkit/Models/Toast.swift index 201880da..c206d9b2 100644 --- a/Bitkit/Models/Toast.swift +++ b/Bitkit/Models/Toast.swift @@ -5,10 +5,29 @@ struct Toast: Equatable { case success, info, lightning, warning, error } + let id: UUID let type: ToastType let title: String let description: String? let autoHide: Bool let visibilityTime: Double let accessibilityIdentifier: String? + + init( + id: UUID = UUID(), + type: ToastType, + title: String, + description: String? = nil, + autoHide: Bool = true, + visibilityTime: Double = 4.0, + accessibilityIdentifier: String? = nil + ) { + self.id = id + self.type = type + self.title = title + self.description = description + self.autoHide = autoHide + self.visibilityTime = visibilityTime + self.accessibilityIdentifier = accessibilityIdentifier + } } diff --git a/Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift b/Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift index c0d04a92..2d1100d1 100644 --- a/Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift +++ b/Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift @@ -133,13 +133,15 @@ struct ElectrumSettingsScreen: View { app.toast( type: .success, title: t("settings__es__server_updated_title"), - description: t("settings__es__server_updated_message", variables: ["host": host, "port": port]) + description: t("settings__es__server_updated_message", variables: ["host": host, "port": port]), + accessibilityIdentifier: "ElectrumUpdatedToast" ) } else { app.toast( type: .warning, title: t("settings__es__error_peer"), - description: errorMessage ?? t("settings__es__server_error_description") + description: errorMessage ?? t("settings__es__server_error_description"), + accessibilityIdentifier: "ElectrumErrorToast" ) } } diff --git a/Bitkit/Views/Settings/Advanced/RgsSettingsScreen.swift b/Bitkit/Views/Settings/Advanced/RgsSettingsScreen.swift index ace99231..4e5df643 100644 --- a/Bitkit/Views/Settings/Advanced/RgsSettingsScreen.swift +++ b/Bitkit/Views/Settings/Advanced/RgsSettingsScreen.swift @@ -91,13 +91,15 @@ struct RgsSettingsScreen: View { app.toast( type: .success, title: t("settings__rgs__update_success_title"), - description: t("settings__rgs__update_success_description") + description: t("settings__rgs__update_success_description"), + accessibilityIdentifier: "RgsUpdatedToast" ) } else { app.toast( type: .warning, title: tTodo("settings__rgs__error_peer"), - description: errorMessage ?? tTodo("settings__rgs__server_error_description") + description: errorMessage ?? tTodo("settings__rgs__server_error_description"), + accessibilityIdentifier: "RgsErrorToast" ) } }