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
23 changes: 19 additions & 4 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,9 @@ struct ConversationScreen: View {
onReachTop: { if pagesHistory { coordinator?.reachedTop() } },
onRetry: retry,
onCashCardTap: openCurrencyInfo,
onPaymentRequestTap: { paymentRequestDetails = PaymentRequestSelection(id: PaymentRequestID(data: $0)) },
onPaymentRequestTap: { data in
afterKeyboardLowered { paymentRequestDetails = PaymentRequestSelection(id: PaymentRequestID(data: data)) }
},
onOpenURL: openLink,
onMentionTap: openMention,
onShareProfile: shareProfile,
Expand Down Expand Up @@ -723,7 +725,7 @@ struct ConversationScreen: View {
JoinRequestsAnchor(
requests: hostRequests,
avatarData: { sessionContainer.profileAvatars.data(for: $0) },
onReview: { router.push(.joinRequests(conversationID)) }
onReview: { afterKeyboardLowered { router.push(.joinRequests(conversationID)) } }
)
.padding(.horizontal, BarMetrics.edgeInset)
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { anchorHeight = $0 }
Expand Down Expand Up @@ -920,7 +922,20 @@ struct ConversationScreen: View {
}

private func payRequest(_ id: PaymentRequestID) {
Task { await requestPayFlow.start(id, sessionContainer: sessionContainer) }
afterKeyboardLowered {
Task { await requestPayFlow.start(id, sessionContainer: sessionContainer) }
}
}

/// Runs `present` once the keyboard is out of sight, so a cover or push never starts over a raised
/// keyboard. The composer's field holds focus, so resigning it also stops it re-raising the keyboard
/// when the presentation closes.
private func afterKeyboardLowered(_ present: @escaping () -> Void) {
guard isKeyboardUp else { return present() }
Task {
await KeyboardHideWaiter().wait { KeyboardSuppressor.lower() }
present()
}
}

/// The fetches the transcript needs: gate token names, sender names, and avatars.
Expand Down Expand Up @@ -1510,7 +1525,7 @@ struct ConversationScreen: View {
switch sendTarget {
case .privateGroup:
// Requesting needs no balance, so the give-cash gate waits for the Send choice.
isShowingMoneyActions = true
afterKeyboardLowered { isShowingMoneyActions = true }
case .contact, .tip, .group:
presentSendAmount(sendTarget)
}
Expand Down
44 changes: 0 additions & 44 deletions Flipcash/Core/Screens/Main/Search/ChatSearchScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,50 +222,6 @@ private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}

/// Runs `resign` and returns once the keyboard is out of sight. `keyboardDidHide` lands well after
/// that, since the hide is a spring whose tail runs on unseen below the screen, so this waits on
/// `keyboardWillHide` plus part of its animation instead. A hardware keyboard posts neither, so it
/// gives up after `timeout`.
@MainActor
private final class KeyboardHideWaiter {
/// The share of the hide animation after which the keyboard is visually gone.
private static let visibleShare = 0.6

private var continuation: CheckedContinuation<Void, Never>?
private var observer: NSObjectProtocol?

func wait(timeout: Duration = .milliseconds(450), resign: () -> Void) async {
await withCheckedContinuation { continuation in
self.continuation = continuation
observer = NotificationCenter.default.addObserver(
forName: UIResponder.keyboardWillHideNotification,
object: nil,
queue: .main
) { [weak self] notification in
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0
MainActor.assumeIsolated { self?.finish(after: .seconds(duration * Self.visibleShare)) }
}
resign()
finish(after: timeout)
}
}

private func finish(after delay: Duration) {
Task { [weak self] in
try? await Task.sleep(for: delay)
self?.finish()
}
}

private func finish() {
guard let continuation else { return }
self.continuation = nil
if let observer { NotificationCenter.default.removeObserver(observer) }
observer = nil
continuation.resume()
}
}

// MARK: - Search bar -

/// The bottom bar: back to Chats, the query field with its clear button, and close.
Expand Down
50 changes: 50 additions & 0 deletions Flipcash/Utilities/KeyboardHideWaiter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// KeyboardHideWaiter.swift
// Flipcash
//

import UIKit

/// Runs `resign` and returns once the keyboard is out of sight. `keyboardDidHide` lands well after
/// that, since the hide is a spring whose tail runs on unseen below the screen, so this waits on
/// `keyboardWillHide` plus part of its animation instead. A hardware keyboard posts neither, so it
/// gives up after `timeout`.
@MainActor
final class KeyboardHideWaiter {
/// The share of the hide animation after which the keyboard is visually gone.
private static let visibleShare = 0.6

private var continuation: CheckedContinuation<Void, Never>?
private var observer: NSObjectProtocol?

func wait(timeout: Duration = .milliseconds(450), resign: () -> Void) async {
await withCheckedContinuation { continuation in
self.continuation = continuation
observer = NotificationCenter.default.addObserver(
forName: UIResponder.keyboardWillHideNotification,
object: nil,
queue: .main
) { [weak self] notification in
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0
MainActor.assumeIsolated { self?.finish(after: .seconds(duration * Self.visibleShare)) }
}
resign()
finish(after: timeout)
}
}

private func finish(after delay: Duration) {
Task { [weak self] in
try? await Task.sleep(for: delay)
self?.finish()
}
}

private func finish() {
guard let continuation else { return }
self.continuation = nil
if let observer { NotificationCenter.default.removeObserver(observer) }
observer = nil
continuation.resume()
}
}
Loading