mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
A tvOS sheet fills most of the screen, far too much for a two-button question. The shared sheet host now presents link action confirmations as an alert there, answering cancel when it goes away without a choice.
160 lines
5.0 KiB
Swift
160 lines
5.0 KiB
Swift
//
|
|
// LinkActionConfirmationSheet.swift
|
|
// Yattee
|
|
//
|
|
// Asks before a URL scheme action changes saved state. Any web page can open
|
|
// a yattee:// link, so queueing, bookmarking, downloading and subscribing
|
|
// wait for the user unless Settings allows them to run on their own.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
/// A pending question for `LinkActionConfirmationSheet`. Answered exactly once:
|
|
/// by a button, or as "cancel" when the sheet goes away any other way.
|
|
@MainActor
|
|
final class LinkActionConfirmation {
|
|
let id = UUID()
|
|
let title: String
|
|
let subject: String?
|
|
let detail: String?
|
|
let confirmTitle: String
|
|
let systemImage: String
|
|
|
|
private var continuation: CheckedContinuation<Bool, Never>?
|
|
|
|
init(title: String, subject: String?, detail: String?, confirmTitle: String, systemImage: String) {
|
|
self.title = title
|
|
self.subject = subject
|
|
self.detail = detail
|
|
self.confirmTitle = confirmTitle
|
|
self.systemImage = systemImage
|
|
}
|
|
|
|
/// Presents the sheet through the top-most host and waits for the answer.
|
|
func ask(using presenter: PresentationRouter) async -> Bool {
|
|
await withCheckedContinuation { continuation in
|
|
self.continuation = continuation
|
|
presenter.present(.linkActionConfirmation(self))
|
|
}
|
|
}
|
|
|
|
func answer(_ allowed: Bool) {
|
|
continuation?.resume(returning: allowed)
|
|
continuation = nil
|
|
}
|
|
}
|
|
|
|
extension LinkActionConfirmation: Equatable {
|
|
nonisolated static func == (lhs: LinkActionConfirmation, rhs: LinkActionConfirmation) -> Bool {
|
|
lhs === rhs
|
|
}
|
|
}
|
|
|
|
struct LinkActionConfirmationSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
let confirmation: LinkActionConfirmation
|
|
|
|
var body: some View {
|
|
content
|
|
.onDisappear {
|
|
// Swiped away or replaced by another sheet: treat as cancel
|
|
confirmation.answer(false)
|
|
}
|
|
}
|
|
|
|
private var content: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 24)
|
|
.padding(.bottom, 20)
|
|
|
|
Divider()
|
|
|
|
HStack(spacing: 12) {
|
|
Button(role: .cancel) { respond(false) } label: {
|
|
Text(String(localized: "common.cancel"))
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Button { respond(true) } label: {
|
|
Label(confirmation.confirmTitle, systemImage: confirmation.systemImage)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.7)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
.controlSize(.large)
|
|
.padding(20)
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
#if os(macOS)
|
|
.frame(width: 380)
|
|
#endif
|
|
#if !os(tvOS)
|
|
.presentationDetents([.height(210)])
|
|
.presentationDragIndicator(.visible)
|
|
#endif
|
|
}
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(confirmation.title)
|
|
.font(.headline)
|
|
if let subject = confirmation.subject {
|
|
Text(subject)
|
|
.font(.subheadline)
|
|
.lineLimit(2)
|
|
}
|
|
if let detail = confirmation.detail {
|
|
Text(detail)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private func respond(_ allowed: Bool) {
|
|
confirmation.answer(allowed)
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
#if os(tvOS)
|
|
extension View {
|
|
/// Presents a link action confirmation as an alert. tvOS sheets fill most
|
|
/// of the screen, too much for a two-button question.
|
|
func linkActionConfirmationAlert(
|
|
_ confirmation: LinkActionConfirmation?,
|
|
onDismiss: @escaping () -> Void
|
|
) -> some View {
|
|
alert(
|
|
confirmation?.title ?? "",
|
|
isPresented: Binding {
|
|
confirmation != nil
|
|
} set: { isPresented in
|
|
if !isPresented { onDismiss() }
|
|
},
|
|
presenting: confirmation
|
|
) { confirmation in
|
|
Button(confirmation.confirmTitle) { confirmation.answer(true) }
|
|
Button(String(localized: "common.cancel"), role: .cancel) { confirmation.answer(false) }
|
|
} message: { confirmation in
|
|
Text([confirmation.subject, confirmation.detail].compactMap { $0 }.joined(separator: "\n"))
|
|
}
|
|
.onChange(of: confirmation) { previous, _ in
|
|
// Gone without an answer (Menu button, external navigation, another
|
|
// sheet): treat as cancel. Deferred so a button's answer comes first.
|
|
guard let previous else { return }
|
|
Task { @MainActor in previous.answer(false) }
|
|
}
|
|
}
|
|
}
|
|
#endif
|