Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 78 additions & 40 deletions Bitkit/Components/ToastView.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -71,7 +106,10 @@ struct ToastView: View {
autoHide: true,
visibilityTime: 4.0,
accessibilityIdentifier: nil
), onDismiss: {}
),
onDismiss: {},
onDragStart: {},
onDragEnd: {}
)
.preferredColorScheme(.dark)
}
6 changes: 4 additions & 2 deletions Bitkit/Managers/ScannerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
139 changes: 128 additions & 11 deletions Bitkit/Managers/ToastWindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,24 @@ class ToastWindowManager: ObservableObject {
private var toastWindow: PassThroughWindow?
private var toastHostingController: UIHostingController<ToastWindowView>?

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<Void, Never>?
private var autoHideStartTime: Date?
private var autoHideDuration: Double = 0

private init() {
// Set up the window when the app starts
Expand All @@ -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)) {
Expand All @@ -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() {
Expand All @@ -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
}

Expand All @@ -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()
}
}
19 changes: 19 additions & 0 deletions Bitkit/Models/Toast.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
6 changes: 4 additions & 2 deletions Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
}
}
Expand Down
Loading
Loading