taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

commit 665668fc4fc4523620f2d06fd828569722dd81d6
parent e204f1afe4459fcb1e6b87d2dc6e714b3b81b130
Author: Marc Stibane <marc@taler.net>
Date:   Sun, 16 Aug 2026 19:24:25 +0200

move some HelperViews to TalerCommon

Diffstat:
ATalerCommon/HelperViews/AmountRowV.swift | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
RTalerWallet1/Views/HelperViews/AmountV.swift -> TalerCommon/HelperViews/AmountV.swift | 0
ATalerCommon/HelperViews/Buttons.swift | 461+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATalerCommon/HelperViews/CopyShare.swift | 274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
RTalerWallet1/Views/HelperViews/CurrencyField.swift -> TalerCommon/HelperViews/CurrencyField.swift | 0
ATalerCommon/HelperViews/CurrencyInputView.swift | 269+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
RTalerWallet1/Views/HelperViews/DebugSpacer.swift -> TalerCommon/HelperViews/DebugSpacer.swift | 0
RTalerWallet1/Views/HelperViews/ForEachWithIndex.swift -> TalerCommon/HelperViews/ForEachWithIndex.swift | 0
RTalerWallet1/Views/HelperViews/GradientBorder.swift -> TalerCommon/HelperViews/GradientBorder.swift | 0
ATalerCommon/HelperViews/IconBadge.swift | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATalerCommon/HelperViews/LaunchAnimationView.swift | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
RTalerWallet1/Views/HelperViews/LayoutThatFits.swift -> TalerCommon/HelperViews/LayoutThatFits.swift | 0
RTalerWallet1/Views/HelperViews/ListStyle.swift -> TalerCommon/HelperViews/ListStyle.swift | 0
ATalerCommon/HelperViews/NavLink.swift | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
RTalerWallet1/Views/HelperViews/OptimalSize.swift -> TalerCommon/HelperViews/OptimalSize.swift | 0
RTalerWallet1/Views/HelperViews/QRGeneratorView.swift -> TalerCommon/HelperViews/QRGeneratorView.swift | 0
RTalerWallet1/Views/HelperViews/SingleAxisGeometryReader.swift -> TalerCommon/HelperViews/SingleAxisGeometryReader.swift | 0
ATalerCommon/HelperViews/TextFieldAlert.swift | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
RTalerWallet1/Views/HelperViews/TimeView.swift -> TalerCommon/HelperViews/TimeView.swift | 0
ATalerCommon/HelperViews/TransactionButton.swift | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
RTalerWallet1/Views/HelperViews/TruncationDetectingText.swift -> TalerCommon/HelperViews/TruncationDetectingText.swift | 0
DTalerWallet1/Views/HelperViews/AmountRowV.swift | 107-------------------------------------------------------------------------------
DTalerWallet1/Views/HelperViews/Buttons.swift | 461-------------------------------------------------------------------------------
DTalerWallet1/Views/HelperViews/CopyShare.swift | 274-------------------------------------------------------------------------------
DTalerWallet1/Views/HelperViews/CurrencyInputView.swift | 269-------------------------------------------------------------------------------
DTalerWallet1/Views/HelperViews/IconBadge.swift | 214-------------------------------------------------------------------------------
DTalerWallet1/Views/HelperViews/LaunchAnimationView.swift | 79-------------------------------------------------------------------------------
DTalerWallet1/Views/HelperViews/NavLink.swift | 59-----------------------------------------------------------
DTalerWallet1/Views/HelperViews/TextFieldAlert.swift | 72------------------------------------------------------------------------
DTalerWallet1/Views/HelperViews/TransactionButton.swift | 117-------------------------------------------------------------------------------
30 files changed, 1652 insertions(+), 1652 deletions(-)

diff --git a/TalerCommon/HelperViews/AmountRowV.swift b/TalerCommon/HelperViews/AmountRowV.swift @@ -0,0 +1,107 @@ +/* + * This file is part of GNU Taler, ©2022-26 Taler Systems S.A. + * See LICENSE.md + */ +/** + * @author Marc Stibane + */ +import SwiftUI +import taler_swift + +// Title and Amount +struct AmountRowV: View { + let stack: CallStack? + let title: String + let amount: Amount + let scope: ScopeInfo? + let isNegative: Bool? // show fee with minus (or plus) sign, or no sign if nil + let color: Color + let large: Bool // set to false for QR or IBAN + + var body: some View { + let titleV = Text(title) + .multilineTextAlignment(.leading) + .talerFont(.body) + let amountV = AmountV(stack: stack?.push(), + scope: scope, + amount: amount, + isNegative: isNegative, + strikethrough: false, + large: large) + .foregroundColor(color) + let verticalV = VStack(alignment: .leading) { + titleV + HStack(alignment: .lastTextBaseline) { + Spacer(minLength: 2) + amountV + } + } + Group { + if #available(iOS 16.4, *) { + ViewThatFits(in: .horizontal) { + HStack(alignment: .lastTextBaseline) { + titleV//.border(.orange) + Spacer(minLength: 2) + amountV//.border(.gray) + } + HStack(alignment: .lastTextBaseline) { + titleV//.border(.blue) + .lineLimit(2, reservesSpace: true) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 2) + amountV//.border(.gray) + } + verticalV + } + } else { // view for iOS 15 + verticalV + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .listRowSeparator(.hidden) + } +} +extension AmountRowV { + init(_ title: String, amount: Amount, scope: ScopeInfo?, isNegative: Bool, color: Color) { + self.stack = nil + self.title = title + self.amount = amount + self.scope = scope + self.isNegative = isNegative + self.color = color + self.large = true + } +} + +// MARK: - +fileprivate func talerFromStr(_ from: String) -> Amount { + do { + let amount = try Amount(fromString: from) + return amount + } catch { + return Amount(currency: "Taler", cent: 480) + } +} + +#if DEBUG +@MainActor +fileprivate struct BindingViewContainer: View { + @State private var previewD: CurrencyInfo = CurrencyInfo.zero(DEMOCURRENCY) + var body: some View { + let scope = ScopeInfo.zero(DEMOCURRENCY) + let fee = Amount(currency: DEMOCURRENCY, cent: 20) + AmountRowV("Fee", amount: fee, scope: scope, isNegative: true, color: Color("Outgoing")) + let cents = Amount(currency: DEMOCURRENCY, cent: 480) + AmountRowV("Cents", amount: cents, scope: scope, isNegative: false, color: Color("Incoming")) + let amount = talerFromStr("Taler:4.80") + AmountRowV("Chosen amount to withdraw", amount: amount, scope: scope, isNegative: false, color: Color("Incoming")) + } +} + +#Preview { + List { + BindingViewContainer() + } +} +#endif diff --git a/TalerWallet1/Views/HelperViews/AmountV.swift b/TalerCommon/HelperViews/AmountV.swift diff --git a/TalerCommon/HelperViews/Buttons.swift b/TalerCommon/HelperViews/Buttons.swift @@ -0,0 +1,461 @@ +/* + * This file is part of GNU Taler, ©2022-26 Taler Systems S.A. + * See LICENSE.md + */ +/** + * @author Marc Stibane + */ +import SwiftUI +import Foundation +import AVFoundation + +extension ShapeStyle where Self == Color { + static var random: Color { + Color( + red: .random(in: 0...1), + green: .random(in: 0...1), + blue: .random(in: 0...1) + ) + } +} + +struct HamburgerButton : View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: HAMBURGER) +// Image(systemName: "sidebar.squares.leading") // 􀱦 + } + .talerFont(.title) + .accessibilityLabel(Text("Main Menu", comment: "a11y")) + } +} + +struct LinkButton: View { + let destination: URL + let hintTitle: String + let buttonTitle: String + let a11yHint: String + let badge: String + + @AppStorage("minimalistic") var minimalistic: Bool = false + + var body: some View { + VStack(alignment: .leading) { + if !minimalistic { // show hint that the user should authorize on bank website + Text(hintTitle) + .fixedSize(horizontal: false, vertical: true) // wrap in scrollview + .multilineTextAlignment(.leading) // otherwise + .listRowSeparator(.hidden) + } + Link(destination: destination) { + HStack(spacing: 8.0) { + Image(systemName: LINK) + Text(buttonTitle) + } + } + .buttonStyle(TalerButtonStyle(type: .prominent, badge: badge)) + .accessibilityHint(a11yHint) + } + } +} + +struct QRButton : View { + let hideTitle: Bool + let action: () -> Void + + @AppStorage("minimalistic") var minimalistic: Bool = false + @State private var showCameraAlert: Bool = false + + private var openSettingsButton: some View { + Button("Open Settings") { + showCameraAlert = false + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + } + } + let closingAnnouncement = String(localized: "Closing Camera", comment: "a11y") + + var defaultPriorityAnnouncement = String(localized: "Opening Camera", comment: "a11y") + + var highPriorityAnnouncement: AttributedString { + var highPriorityString = AttributedString(localized: "Camera Active", comment: "a11y") + if #available(iOS 17.0, *) { + highPriorityString.accessibilitySpeechAnnouncementPriority = .high + } + return highPriorityString + } + @MainActor + private func checkCameraAvailable() -> Void { + // Open Camera when QR-Button was tapped + announce(defaultPriorityAnnouncement) + + AVCaptureDevice.requestAccess(for: .video, completionHandler: { (granted: Bool) -> Void in + if granted { + action() + if #available(iOS 17.0, *) { + AccessibilityNotification.Announcement(highPriorityAnnouncement).post() + } else { + let cameraActive = String(localized: "Camera Active", comment: "a11y") + announce(cameraActive) + } + } else { + showCameraAlert = true + } + }) + } + + var body: some View { + let dismissAlertButton = Button("Cancel", role: .cancel) { + announce(closingAnnouncement) + showCameraAlert = false + } + let scanText = String(localized: "Scan QR code", comment: "Button title, a11y") + let qrImage = Image(systemName: QRBUTTON) + let qrText = Text(qrImage) + Button(action: checkCameraAvailable) { + if hideTitle { + qrImage + .resizable() + .scaledToFit() + .foregroundStyle(WalletColors().primaryAccent) + } else if minimalistic { + let width = UIScreen.screenWidth / 7 + qrText.talerFont(.largeTitle) + .padding(.horizontal, width) + } else { + HStack(spacing: 16) { + qrText.talerFont(.title) + Text(scanText) + }.padding(.horizontal) + } + } + .accessibilityLabel(scanText) + .alert("Scanning QR-codes requires access to the camera", + isPresented: $showCameraAlert, + actions: { openSettingsButton + dismissAlertButton }, + message: { Text("Please allow camera access in Settings.") }) // Scanning QR-codes + } +} + +struct DoneButton : View { + let titleStr: String? + let accessibilityLabelStr: String + let action: () -> Void + + var body: some View { + Button(action: action) { + if let titleStr { + Text(titleStr) + } else { + Image(systemName: CHECKMARK) // 􀆅 + } + } + .tint(WalletColors().primaryAccent) + .talerFont(.title) + .accessibilityLabel(accessibilityLabelStr) + } +} + +struct PlusButton : View { + let accessibilityLabelStr: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: PLUS) + } + .tint(WalletColors().primaryAccent) + .talerFont(.title) + .accessibilityLabel(accessibilityLabelStr) + } +} + +@available(iOS 26.0, *) +struct SettingsButton26 : View { + let sortPriority: Double + + var body: some View { + Button { + // will trigger NavigationLink + NotificationCenter.default.post(name: .SettingsAction, object: nil) + } label: { + Label(TalerTab.settings.title, systemImage: TalerTab.settings.sysImg) + .labelStyle(.iconOnly) + } +// .padding() + .buttonStyle(.glass) + .accessibilitySortPriority(sortPriority) + } +} + +struct SettingsButton : View { + let accessibilityLabelStr: String + let action: () -> Void + + var body: some View { + let button = Button(action: action) { + Image(systemName: SETTINGS) + } + .tint(WalletColors().primaryAccent) + .talerFont(.title) + .accessibilityLabel(accessibilityLabelStr) +// if #available(iOS 26.0, *) { +// return button.buttonStyle(.glass) +// } + return button + } +} +struct TalerPasteButton : View { + let accessibilityLabelStr: String + let action: () -> Void + + var body: some View { + let button = Button(action: action) { + Image(systemName: PASTE) + } + .tint(WalletColors().primaryAccent) + .talerFont(.title) + .accessibilityLabel(accessibilityLabelStr) +// if #available(iOS 26.0, *) { +// return button.buttonStyle(.glass) +// } + return button + } +} + +struct ZoomInButton : View { + let accessibilityLabelStr: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(ICONNAME_ZOOM_IN, SYSTEM_ZOOM_IN) + } + .tint(WalletColors().primaryAccent) + .talerFont(.title) + .accessibilityLabel(accessibilityLabelStr) + } +} + +struct ZoomOutButton : View { + let accessibilityLabelStr: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(ICONNAME_ZOOM_OUT, SYSTEM_ZOOM_OUT) + } + .tint(WalletColors().primaryAccent) + .talerFont(.title) + .accessibilityLabel(accessibilityLabelStr) + } +} + +struct BackButton : View { + let action: () -> Void + + var body: some View { + Button(action: action) { + let name = ICONNAME_INCOMING + ICONNAME_FILL + let sysName = SYSTEM_INCOMING4 + ICONNAME_FILL + Image(name, sysName, fallback: FALLBACK_INCOMING) + } + .tint(WalletColors().primaryAccent) + .talerFont(.largeTitle) + .accessibilityLabel(Text("Back", comment: "a11y")) + } +} + +struct ForwardButton : View { + let enabled: Bool + let action: () -> Void + + var body: some View { + let myAction = { + if enabled { + action() + } + } + Button(action: myAction) { + let imageName = enabled ? ICONNAME_OUTGOING + ICONNAME_FILL + : ICONNAME_OUTGOING + let sysName = enabled ? SYSTEM_OUTGOING4 + ICONNAME_FILL + : SYSTEM_OUTGOING4 + Image(imageName, sysName, fallback: FALLBACK_OUTGOING) + } + .tint(WalletColors().primaryAccent) + .talerFont(.largeTitle) + .accessibilityLabel(Text("Continue", comment: "a11y")) + } +} + +struct ArrowUpButton : View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: ARROW_TOP) // 􀄿 + } + .tint(WalletColors().primaryAccent) + .talerFont(.title2) + .accessibilityLabel(Text("Scroll up", comment: "a11y")) + } +} + +struct ArrowDownButton : View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: ARROW_BOT) // 􀅀 + } + .tint(WalletColors().primaryAccent) + .talerFont(.title2) + .accessibilityLabel(Text("Scroll down", comment: "a11y")) + } +} + +struct ReloadButton : View { + let disabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: RELOAD) // 􀅈 + } + .tint(WalletColors().primaryAccent) + .talerFont(.title) + .accessibilityLabel(Text("Reload", comment: "a11y")) + .disabled(disabled) + } +} + +enum TalerButtonStyleType { + case plain + case bordered + case prominent +} +struct TalerButtonStyle: ButtonStyle { + var type: TalerButtonStyleType = .plain + var dimmed: Bool = false + var narrow: Bool = false + var one: Bool = false + var disabled: Bool = false + var aligned: TextAlignment = .center + var badge: String = EMPTYSTRING + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + + public func makeBody(configuration: ButtonStyleConfiguration) -> some View { + // configuration.role = type == .prominent ? .primary : .normal Only on macOS + MyBigButton(foreColor: foreColor(type: type, pressed: configuration.isPressed, + scheme: colorScheme, contrast: colorSchemeContrast, disabled: disabled), + backColor: backColor(type: type, pressed: configuration.isPressed, disabled: disabled), + dimmed: dimmed, + configuration: configuration, + disabled: disabled, + narrow: narrow, + one: one, + aligned: aligned, + badge: badge, + scheme: colorScheme, + contrast: colorSchemeContrast) + } + + func foreColor(type: TalerButtonStyleType, + pressed: Bool, + scheme: ColorScheme, + contrast: ColorSchemeContrast, + disabled: Bool) -> Color { + if type == .plain { + return WalletColors().fieldForeground // primary text color + } + return WalletColors().buttonForeColor(pressed: pressed, + disabled: disabled, + scheme: scheme, + contrast: contrast, + prominent: type == .prominent) + } + func backColor(type: TalerButtonStyleType, pressed: Bool, disabled: Bool) -> Color { + if type == .plain && !pressed { + return Color.clear + } + return WalletColors().buttonBackColor(pressed: pressed, + disabled: disabled, + prominent: type == .prominent) + } + + struct BackgroundView: View { + let color: Color + let dimmed: Bool + var body: some View { + RoundedRectangle( + cornerRadius: 15, + style: .continuous + ) + .fill(color) + .opacity(dimmed ? 0.6 : 1.0) + } + } + + struct MyBigButton: View { +// var type: TalerButtonStyleType + let foreColor: Color + let backColor: Color + let dimmed: Bool + let configuration: ButtonStyle.Configuration + let disabled: Bool + let narrow: Bool + let one: Bool + let aligned: TextAlignment + var badge: String + var scheme: ColorScheme + var contrast: ColorSchemeContrast + + var body: some View { + let aligned2: Alignment = (aligned == .center) ? Alignment.center + : (aligned == .leading) ? Alignment.leading + : Alignment.trailing + let hasBadge = !badge.isEmpty + let buttonLabel = configuration.label + .multilineTextAlignment(aligned) + .lineLimit(one ? 1 : 3) + .talerFont(.title3) // narrow ? .title3 : .title2 + .frame(maxWidth: narrow ? nil : .infinity, alignment: aligned2) + .padding(.vertical, 10) + .padding(.horizontal, hasBadge ? 0 : 6) + .foregroundColor(foreColor) + .background(BackgroundView(color: backColor, dimmed: dimmed)) + .contentShape(Rectangle()) // make sure the button can be pressed even if backgroundColor == clear + .scaleEffect(configuration.isPressed ? 0.95 : 1) + .animation(.spring(response: 0.1), value: configuration.isPressed) + .disabled(disabled) + if hasBadge { + let badgeColor: Color = (badge == CONFIRM_BANK) ? WalletColors().confirm + : WalletColors().attention + let badgeV = Image(systemName: badge) + .talerFont(.caption) + HStack(alignment: .top, spacing: 0) { + badgeV.foregroundColor(.clear) + buttonLabel + badgeV.foregroundColor(badgeColor) + } + } else { + buttonLabel + } + } + } +} +// MARK: - +#if DEBUG +fileprivate struct ContentView_Previews: PreviewProvider { + static var previews: some View { + let testButtonTitle = String("Placeholder") + Button(testButtonTitle) {} + .buttonStyle(TalerButtonStyle(type: .bordered, aligned: .trailing)) + } +} +#endif diff --git a/TalerCommon/HelperViews/CopyShare.swift b/TalerCommon/HelperViews/CopyShare.swift @@ -0,0 +1,274 @@ +/* + * This file is part of GNU Taler, ©2022-26 Taler Systems S.A. + * See LICENSE.md + */ +/** + * @author Marc Stibane + */ +import UniformTypeIdentifiers +import SwiftUI +import SymLog + +@MainActor +struct FeedbackButton: View { + private let symLog = SymLogV(0) + let title: String? + let image: UIImage? + let isDisabled: Bool + let action: () -> Void + + @EnvironmentObject private var controller: Controller + @State private var scale: CGFloat = 1.0 + + public init(_ title: String? = nil, + image: UIImage? = nil, + disabled: Bool = false, + action: @escaping @MainActor () -> Void) { + self.title = title + self.image = image + self.isDisabled = disabled + self.action = action + } + + private func triggerPulse() { + // First scale down quickly + withAnimation(.easeIn(duration: 0.1)) { + scale = 0.8 + } + // Then bounce back bigger + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.easeOut(duration: 0.25)) { + scale = 1.15 + } + } + // Finally settle to normal + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + withAnimation(.easeInOut(duration: 0.25)) { + scale = 1.0 + } + } + } + + var body: some View { + Button(title ?? EMPTYSTRING) { + symLog.log(title ?? EMPTYSTRING) + controller.hapticFeedback(.medium) + action() + triggerPulse() + } + .buttonStyle(TalerButtonStyle(type: .bordered, disabled: isDisabled)) + } +} +// MARK: - +@MainActor +struct CopyButton: View { + private let symLog = SymLogV(0) + let textToCopy: String + @Binding var isCopied: Bool? + let image: UIImage? + let vertical: Bool + let title: String? + + @Environment(\.isEnabled) private var isEnabled: Bool + @EnvironmentObject private var controller: Controller + + @State private var scale: CGFloat = 1.0 + + init(_ textToCopy: String, isCopied: Binding<Bool?>? = nil, + vertical: Bool, image: UIImage? = nil) { + self.textToCopy = textToCopy + self._isCopied = isCopied ?? Binding.constant(nil) + self.image = image + self.vertical = vertical + self.title = nil + } + + init(_ textToCopy: String, isCopied: Binding<Bool?>? = nil, + title: String, image: UIImage? = nil) { + self.textToCopy = textToCopy + self._isCopied = isCopied ?? Binding.constant(nil) + self.image = image + self.vertical = false + self.title = title + } + + func copyAction() -> Void { + symLog.log(textToCopy) + triggerPulse() + controller.hapticFeedback(.medium) + let pasteboard = UIPasteboard.general + if let image { +// pasteboard.image = image + let strItem = [UTType.plainText.identifier : textToCopy] + let imgItem = [UTType.image.identifier : image] + pasteboard.items = [imgItem, strItem] // iOS27 Notes.app crashes if text comes first! + } else { + pasteboard.string = textToCopy + } + if isCopied != nil { + isCopied = true + } + } + + private func triggerPulse() { + // First scale down quickly + withAnimation(.easeIn(duration: 0.1)) { + scale = 0.8 + } + // Then bounce back bigger + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.easeOut(duration: 0.25)) { + scale = 1.15 + } + } + // Finally settle to normal + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + withAnimation(.easeInOut(duration: 0.25)) { + scale = 1.0 + } + } + } + + var body: some View { + Button(action: copyAction) { + let image = Image(systemName: COPY1) // 􀉁 + .accessibility(hidden: true) + + if vertical { + VStack { + let shortCopy = String(localized: "Copy.short", defaultValue: "Copy", comment: "5 letters max, else abbreviate") + image + Text(shortCopy) + } + } else { + let longCopy = String(localized: "Copy.long", defaultValue: "Copy", comment: "may be a bit longer") + HStack { + image + Text(title ?? longCopy) + } + } + } + .tint(WalletColors().primaryAccent) + .talerFont(.body) + .scaleEffect(scale) + .disabled(!isEnabled) + } +} +// MARK: - +struct ShareType: Hashable { + let textToShare: String + let image: UIImage? +} +// MARK: - +@MainActor +struct ShareButton: View { + private let symLog = SymLogV(0) + let textToShare: String + let image: UIImage? + let title: String + + @State private var scale: CGFloat = 1.0 + + init(_ textToShare: String, image: UIImage? = nil) { + self.textToShare = textToShare + self.image = image + self.title = String(localized: "Share") + } + init(_ textToShare: String, title: String, image: UIImage? = nil) { + self.textToShare = textToShare + self.image = image + self.title = title + } + + @Environment(\.isEnabled) private var isEnabled: Bool + @EnvironmentObject private var controller: Controller + + @MainActor + func dismissAndPost() { + dismissTop() + let shareType = ShareType(textToShare: textToShare, image: image) + let userInfo = [NOTIFICATIONSHARE: shareType] + NotificationCenter.default.post(name: .ShareAction, object: nil, userInfo: userInfo) // will trigger NavigationLink + } + + func shareAction() -> Void { + symLog.log(textToShare) + controller.hapticFeedback(.soft) + Task { + // First scale down quickly + withAnimation(.easeIn(duration: 0.1)) { + scale = 0.8 + } + // Then bounce back bigger + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.easeOut(duration: 0.25)) { + scale = 1.15 + } + dismissAndPost() + } + // Finally settle to normal + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + withAnimation(.easeInOut(duration: 0.25)) { + scale = 1.0 + } + } + } + } + + var body: some View { + Button(action: shareAction) { + HStack { + Image(systemName: SHARE) // 􀈂 + .accessibility(hidden: true) + Text(title) + } + } + .tint(WalletColors().primaryAccent) + .talerFont(.body) + .scaleEffect(scale) + .disabled(!isEnabled) + } +} +// MARK: - +struct CopyShare: View { + @Environment(\.isEnabled) private var isEnabled: Bool + + let textToCopy: String + let image: UIImage? + + init(_ string: String, image: UIImage? = nil) { + self.textToCopy = string + self.image = image + } + + var body: some View { + let copyB = CopyButton(textToCopy, vertical: false, image: image) + .buttonStyle(TalerButtonStyle(type: .bordered)) + let share = ShareButton(textToCopy, image: image) + .buttonStyle(TalerButtonStyle(type: .bordered)) + + let vLayout = VStack { + copyB + share + } + + if #available(iOS 16.0, *) { + let hLayout = HStack(spacing: HSPACING) { + copyB + share + } + ViewThatFits(in: .horizontal) { + hLayout + vLayout + } + } else { + vLayout + } + } +} +// MARK: - +struct CopyShare_Previews: PreviewProvider { + static var previews: some View { + CopyShare("Hallö", image: nil) + } +} diff --git a/TalerWallet1/Views/HelperViews/CurrencyField.swift b/TalerCommon/HelperViews/CurrencyField.swift diff --git a/TalerCommon/HelperViews/CurrencyInputView.swift b/TalerCommon/HelperViews/CurrencyInputView.swift @@ -0,0 +1,269 @@ +/* + * This file is part of GNU Taler, ©2022-26 Taler Systems S.A. + * See LICENSE.md + */ +/** + * @author Marc Stibane + */ +import SwiftUI +import taler_swift + +fileprivate let replaceable = 500 +fileprivate let shortcutValues = [5000,2500,1000] // TODO: adapt for ¥ + +struct ShortcutButton: View { + let scope: ScopeInfo? + let currency: String + let currencyField: CurrencyField + let shortcut: Int + let available: Amount? // disable if available < value + let action: (Int, CurrencyField) -> Void + + func makeButton(with newShortcut: Int) -> ShortcutButton { + ShortcutButton(scope: scope, + currency: currency, + currencyField: currencyField, + shortcut: newShortcut, + available: available, + action: action) + } + + func isDisabled(shortie: Amount) -> Bool { + if let available { + return available.value < shortie.value + } + return false + } + + var body: some View { +#if PRINT_CHANGES + let _ = Self._printChanges() +// let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + let shortie = Amount(currency: currency, cent: UInt64(shortcut)) // TODO: adapt for ¥ + let title = shortie.formatted(scope, isNegative: false) + let shortcutLabel = String(localized: "Shortcut", comment: "a11y: $50,$25,$10,$5 shortcut buttons") + let a11yLabel = "\(shortcutLabel) \(title.1)" + Button(action: { action(shortcut, currencyField)} ) { + Text(title.0) + .lineLimit(1) + .talerFont(.callout) + } +// .frame(maxWidth: .infinity) + .disabled(isDisabled(shortie: shortie)) + .buttonStyle(.bordered) + .accessibilityLabel(a11yLabel) + } +} +// MARK: - +struct CurrencyInputView: View { + let scope: ScopeInfo? + @Binding var amount: Amount // the `value´ + let amountLastUsed: Amount + let available: Amount? + let title: String? + let a11yTitle: String + let shortcutAction: ((_ amount: Amount) -> Void)? + + @EnvironmentObject private var controller: Controller + + @State private var hasBeenShown = false + @State private var useShortcut = 0 + // `body´ builds a new CurrencyField each time, but the text field is created only + // once - keep the handle to it here, where it survives the re-evaluations + @State private var fieldHandle = CurrencyFieldHandle() + + @MainActor + func action(shortcut: Int, currencyField: CurrencyField) { + let shortie = Amount(currency: amount.currencyStr, cent: UInt64(shortcut)) // TODO: adapt for ¥ + if let shortcutAction { + shortcutAction(shortie) + } else { + useShortcut = shortcut + currencyField.updateText(amount: shortie) + amount = shortie + currencyField.resignFirstResponder() + } + } + + @MainActor + func shortcut(for value: Int,_ currencyField: CurrencyField) -> ShortcutButton { + var shortcut = value + if value == replaceable { + if !amountLastUsed.isZero { + let lastUsedD = amountLastUsed.value + let lastUsedI = lround(lastUsedD * 100) + if !shortcutValues.contains(lastUsedI) { + shortcut = lastUsedI + } } } + return ShortcutButton(scope: scope, + currency: amount.currencyStr, + currencyField: currencyField, + shortcut: shortcut, + available: available, + action: action) + } + + @MainActor + func shortcuts(_ currencyField: CurrencyField, _ currencyInfo: CurrencyInfo) -> [ShortcutButton] { + var buttons: [ShortcutButton] = [] + if let commonAmounts = currencyInfo.commonAmounts { + buttons = commonAmounts.prefix(4).map { amount in + shortcut(for: Int(amount.centValue), currencyField) + } + } else { + buttons = shortcutValues.map { value in + shortcut(for: value, currencyField) + } + buttons.append(shortcut(for: replaceable, currencyField)) + } + return buttons + } + + func availableString(_ availableStr: String) -> String { + String(localized: "Available for transfer: \(availableStr)") + } + + var a11yLabel: String { // format currency for a11y + availableString(available?.readableDescription ?? String(localized: "unknown")) + } + + func heading() -> (String, String)? { + if let title { + return (title, title) + } + if let available { + let formatted = available.formatted(scope, isNegative: false) + return (availableString(formatted.0), availableString(formatted.1)) + } + return nil + } + + func currencyInfo() -> CurrencyInfo { + if let scope { + return controller.info(for: scope, controller.currencyTicker) + } else { + return controller.info2(for: amount.currencyStr, controller.currencyTicker) + } + } + + var body: some View { +#if PRINT_CHANGES + let _ = Self._printChanges() +// let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + let currencyInfo = currencyInfo() + let currencyField = CurrencyField(currencyInfo, amount: $amount, handle: fieldHandle) + VStack (alignment: .center) { // center shortcut buttons + if let heading = heading() { + Text(heading.0) + .padding(.horizontal, 4) + .padding(.top) + .frame(maxWidth: .infinity, alignment: title != nil ? .leading : .trailing) + .talerFont(.title2) + .accessibilityLabel(heading.1) + .padding(.bottom, -6) + } + currencyField + .accessibilityLabel(a11yTitle) + .frame(maxWidth: .infinity, alignment: .trailing) + .foregroundColor(WalletColors().fieldForeground) // text color +// .background(WalletColors().fieldBackground) // problem: white corners + .talerFont(.title2) + .textFieldStyle(.roundedBorder) + .onTapGesture { + if useShortcut != 0 { + amount = Amount.zero(currency: amount.currencyStr) + useShortcut = 0 + } + } + if #available(iOS 16.4, *) { + let shortcuts = shortcuts(currencyField, currencyInfo) + ViewThatFits(in: .horizontal) { + HStack { + ForEach(shortcuts, id: \.shortcut) { + $0.accessibilityAddTraits($0.shortcut == useShortcut ? .isSelected : []) + } + } + VStack { + let count = shortcuts.count + let half = count / 2 + HStack { + Spacer() + ForEach(0..<half, id: \.self) { index in + let thisShortcut = shortcuts[index] + thisShortcut + .accessibilityAddTraits(thisShortcut.shortcut == useShortcut ? .isSelected : []) + Spacer() + } + } + HStack { + Spacer() + ForEach(half..<count, id: \.self) { index in + let thisShortcut = shortcuts[index] + thisShortcut + .accessibilityAddTraits(thisShortcut.shortcut == useShortcut ? .isSelected : []) + Spacer() + } + } + } + VStack { + ForEach(shortcuts, id: \.shortcut) { + $0.accessibilityAddTraits($0.shortcut == useShortcut ? .isSelected : []) + } + } + } + .padding(.vertical, 6) + } // iOS 16+ only + }.onAppear { // make CurrencyField show the keyboard after 0.4 seconds +#if OIM + let oimModeActive = controller.oimModeActive +#else + let oimModeActive = false +#endif + if hasBeenShown { +// print("❗️Yikes: CurrencyInputView hasBeenShown") + } else if !UIAccessibility.isVoiceOverRunning && !oimModeActive { +// print("❗️CurrencyInputView❗️") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { + hasBeenShown = true + if !oimModeActive { + if !currencyField.becomeFirstResponder() { + print("❗️Yikes❗️ cannot becomeFirstResponder") + } + } + } + } + }.onDisappear { + currencyField.resignFirstResponder() + hasBeenShown = false + } +#if OIM + .onChange(of: controller.oimModeActive) {_ in + currencyField.resignFirstResponder() + } +#endif + } +} +// MARK: - +#if DEBUG +//fileprivate struct Previews: PreviewProvider { +// @MainActor +// struct StateContainer: View { +// @State var amountToPreview = Amount(currency: LONGCURRENCY, cent: 0) +// @State var amountLastUsed = Amount(currency: LONGCURRENCY, cent: 170) +// @State private var previewL: CurrencyInfo = CurrencyInfo.zero(LONGCURRENCY) +// var body: some View { +// CurrencyInputView(amount: $amountToPreview, +// scope: <#ScopeInfo#>, +// amountLastUsed: amountLastUsed, +// available: Amount(currency: LONGCURRENCY, cent: 2000), +// title: "Amount to withdraw:", +// shortcutAction: nil) +// } +// } +// static var previews: some View { +// StateContainer() +// } +//} +#endif diff --git a/TalerWallet1/Views/HelperViews/DebugSpacer.swift b/TalerCommon/HelperViews/DebugSpacer.swift diff --git a/TalerWallet1/Views/HelperViews/ForEachWithIndex.swift b/TalerCommon/HelperViews/ForEachWithIndex.swift diff --git a/TalerWallet1/Views/HelperViews/GradientBorder.swift b/TalerCommon/HelperViews/GradientBorder.swift diff --git a/TalerCommon/HelperViews/IconBadge.swift b/TalerCommon/HelperViews/IconBadge.swift @@ -0,0 +1,214 @@ +/* + * This file is part of GNU Taler, ©2022-26 Taler Systems S.A. + * See LICENSE.md + */ +/** + * @author Marc Stibane + */ +import SwiftUI + +struct PendingIconBadge: View { + let foreColor:Color + let done: Bool + let incoming: Bool + let shouldConfirm: Bool + let needsKYC: Bool + + var body: some View { + let image = incoming && done ? Image(systemName: DONE_INCOMING) // "plus.circle.fill" + : incoming ? Image(systemName: PENDING_INCOMING) // "plus" + // since outgoing money already left the wallet, show DONE_ and not PENDING_OUTGOING + : Image(systemName: DONE_OUTGOING) // "minus.circle" + IconBadge(image: image, + done: false, + foreColor: foreColor, + shouldConfirm: shouldConfirm, + needsKYC: needsKYC, + phase: 0, + firstImage: nil, + secondImage: nil, + wideIcon: nil) + } +} +// MARK: - +struct TransactionIconBadge: View { + var type: TransactionType + var foreColor: Color + let done: Bool + let incoming: Bool + let shouldConfirm: Bool + let needsKYC: Bool + let imageBase64: String? + + @State private var image: Image? = nil + + init(type: TransactionType, foreColor: Color, done: Bool, incoming: Bool, + shouldConfirm: Bool, needsKYC: Bool, imageBase64: String? = nil + ) { + self.type = type + self.foreColor = foreColor + self.done = done + self.incoming = incoming + self.shouldConfirm = shouldConfirm + self.needsKYC = needsKYC + self.imageBase64 = imageBase64 + } + + init(from transaction: TalerTransaction, isDark: Bool, _ increasedContrast: Bool, + imageBase64: String? = nil + ) { + let common = transaction.common + self.type = common.type + let done = transaction.isDone + self.done = done + let incoming = common.isIncoming + self.incoming = incoming + let needsKYC = transaction.isPendingKYC || transaction.isPendingKYCauth + self.needsKYC = needsKYC + self.shouldConfirm = transaction.shouldConfirm + let pending = transaction.isPending || transaction.common.isFinalizing + self.foreColor = .primary + + let doneOrPending = done || pending + let isZero = common.amountEffective.isZero + let refreshZero = common.type.isRefresh && isZero + let textColor = doneOrPending ? .primary + : isDark ? .secondary + : increasedContrast ? Color(.darkGray) + : .secondary // Color(.tertiaryLabel) + let foreColor = refreshZero ? textColor + : pending ? WalletColors().pendingColor(incoming) + : done ? WalletColors().transactionColor(incoming) + : WalletColors().uncompletedColor + self.foreColor = foreColor + self.imageBase64 = imageBase64 + } + + var body: some View { + let badge = IconBadge(image: type.icon(done), + done: true, + foreColor: foreColor, + shouldConfirm: shouldConfirm, + needsKYC: needsKYC, + phase: 0, + firstImage: nil, + secondImage: nil, + wideIcon: TransactionType.refund.icon()) + // "arrowshape.turn.up.backward" is wider than all others + if let imageBase64 { + if let image { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 40) + } else { + badge + .opacity(0.3) + .task { + image = Image(imageBase64: imageBase64) + } + } + } else { + badge + } + } +} +// MARK: - +struct ButtonIconBadge: View { + let type: TransactionType + let phase: Int + let foreColor:Color + let done: Bool + + var body: some View { + let isSend = type.isSendCoins + let isRcve = type.isSendInvoice + let isDepo = type.isDeposit + let isWthd = type.isWithdrawal + let left = ICONNAME_PERSON_LEFT + ICONNAME_FILL + let right = ICONNAME_PERSON_RIGHT + let bottom = ICONNAME_PERSON_BOTTOM + ICONNAME_FILL + let top = ICONNAME_BANK + let firstImage = isSend ? Image(left) + : isRcve ? Image(right) + : isDepo ? Image(bottom) + : isWthd ? Image(top) : nil + let secondImage = isSend ? Image(right) + : isRcve ? Image(left) + : isDepo ? Image(top) + : isWthd ? Image(bottom) : nil + IconBadge(image: type.icon(done), + done: true, + foreColor: foreColor, + shouldConfirm: false, + needsKYC: false, + phase: phase, + firstImage: firstImage, + secondImage: secondImage, + wideIcon: TransactionType.peerPushDebit.icon()) + // button is send/receive/withdraw/deposit, never payment or refund + } +} +// MARK: - +struct IconBadge: View { + let image: Image + let done: Bool + let foreColor:Color + let shouldConfirm: Bool + let needsKYC: Bool + let phase: Int + let firstImage: Image? + let secondImage: Image? + let wideIcon: Image? // cheating: ZStack with widest icon to ensure all have the same width + // TODO: EqualIconWidth... + + @ScaledMetric var spacing = 6 // relative to fontSize + @State private var showFirst = false + @State private var showSecond = false + @State private var showImage = true + + var body: some View { +// let _ = Self._printChanges() + HStack(alignment: .top, spacing: -spacing) { + ZStack { + if let wideIcon { + wideIcon.foregroundColor(.clear) + } + if let firstImage, let secondImage { + let duration = 0.66 + ZStack { + image.opacity(showImage ? 1 : 0) + .animation(.easeInOut(duration: duration), value: showImage) + firstImage.opacity(showFirst ? 1 : 0) + .animation(.easeOut(duration: duration), value: showFirst) + secondImage.opacity(showSecond ? 1 : 0) + .animation(.easeIn(duration: duration), value: showSecond) + } .foregroundColor(foreColor) + .onChange(of: phase) { phaseVal in + switch phaseVal { + case 0, 4: showFirst = false; showSecond = false; showImage = true + case 2, 3: showFirst = true; showSecond = false; showImage = false + case 5, 6: showFirst = false; showSecond = true; showImage = false + default: showFirst = false; showSecond = false; showImage = false + } + } + } else { + image.foregroundColor(foreColor) + } + } + // ZStack centers the main icon, so the badge will always be at the same position + let badgeName = needsKYC ? NEEDS_KYC + : CONFIRM_BANK + Image(systemName: badgeName) + .talerFont(.badge) + .foregroundColor(needsKYC ? WalletColors().attention + : shouldConfirm ? WalletColors().confirm + : .clear) + .padding(.top, -2) + }.accessibilityHidden(true) + } +} +// MARK: - +//#Preview { +// IconBadge() +//} diff --git a/TalerCommon/HelperViews/LaunchAnimationView.swift b/TalerCommon/HelperViews/LaunchAnimationView.swift @@ -0,0 +1,79 @@ +/* + * This file is part of GNU Taler, ©2022-26 Taler Systems S.A. + * See LICENSE.md + */ +/** + * @author Marc Stibane + */ +import SwiftUI + +struct LaunchAnimationView: View { + @State private var rotationEnabled = true + var body: some View { + ZStack { + Color(.systemGray3).ignoresSafeArea() + RotatingTaler(size: (350 < UIScreen.screenWidth) ? 200 : 250, + progress: true, + once: true, + rotationEnabled: $rotationEnabled) + .accessibilityLabel(Text("Progress indicator", comment: "a11y")) + } + } +} +// MARK: - +struct RotatingTaler: View { + let size: CGFloat + let progress: Bool + let once: Bool + + @Binding var rotationEnabled: Bool + @State private var rotationDirection = false + + private let animationTimer = Timer + .publish(every: 1.5, on: .current, in: .common) + .autoconnect() + + var body: some View { + let image = Image(TALER_LOGO) + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .padding(10) + .accessibilityLabel(progress ? Text("In progress", comment: "a11y") + : Text("Taler Logo", comment: "a11y")) // decorative logo - with button function + .rotationEffect(rotationDirection ? Angle(degrees: 0) : Angle(degrees: once ? 900 : 720)) + .onReceive(animationTimer) { timerValue in +// print("Timer: \(timerValue), rotationDirection: \(rotationDirection)") + if rotationEnabled { + if !once { + withAnimation(.easeInOut(duration: 1.5)) { + rotationDirection.toggle() + } + } + } + } + .task { + if once { + withAnimation(.easeInOut(duration: 2.0)) { + rotationDirection.toggle() + } + } + } +// if #available(iOS 26.0, *) { +// image +// .glassEffect(.clear) +// } else { + image + .background { + Capsule() + .fill(Color(.systemGray6).opacity(0.7)) + } +// } + } +} +// MARK: - +struct LaunchAnimationView_Previews: PreviewProvider { + static var previews: some View { + LaunchAnimationView() + } +} diff --git a/TalerWallet1/Views/HelperViews/LayoutThatFits.swift b/TalerCommon/HelperViews/LayoutThatFits.swift diff --git a/TalerWallet1/Views/HelperViews/ListStyle.swift b/TalerCommon/HelperViews/ListStyle.swift diff --git a/TalerCommon/HelperViews/NavLink.swift b/TalerCommon/HelperViews/NavLink.swift @@ -0,0 +1,59 @@ +/* + * This file is part of GNU Taler, ©2022-26 Taler Systems S.A. + * See LICENSE.md + */ +/** + * @author Marc Stibane + */ + +import SwiftUI + +/// invisible NavigationLink triggered by a Bool or Int? +/// call either like this +/// .background( NavLink($buttonSelected) { destination } ) +/// or +/// let actions = Group { +/// NavLink(1, $actionSelected) { dest1 } +/// NavLink(2, $actionSelected) { dest2 } +/// } +/// and then +/// .background(actions) + + +struct NavLink <Content : View> : View { + let tag: Int? + @Binding var selection: Int? + @Binding var isActive: Bool + let content: Content + + init(_ tag: Int, + _ selection: Binding<Int?>, + _ isActive: Binding<Bool> = .constant(false), + @ViewBuilder contentBuilder: () -> Content + ) { + self.tag = tag + self._selection = selection + self.content = contentBuilder() + self._isActive = isActive + } + + init(_ isActive: Binding<Bool>, + _ selection: Binding<Int?> = .constant(nil), + @ViewBuilder contentBuilder: () -> Content + ) { + self.tag = nil + self._selection = selection + self.content = contentBuilder() + self._isActive = isActive + } + + var body: some View { + if let tag { // actions: $tabBarModel.actionSelected will hide the tabBar + NavigationLink(destination: content, tag: tag, selection: $selection) + { EmptyView() }.frame(width: 0).opacity(0).hidden() + } else { // shortcuts, AddButton + NavigationLink(destination: content, isActive: $isActive) + { EmptyView() }.frame(width: 0).opacity(0).hidden() + } + } +} diff --git a/TalerWallet1/Views/HelperViews/OptimalSize.swift b/TalerCommon/HelperViews/OptimalSize.swift diff --git a/TalerWallet1/Views/HelperViews/QRGeneratorView.swift b/TalerCommon/HelperViews/QRGeneratorView.swift diff --git a/TalerWallet1/Views/HelperViews/SingleAxisGeometryReader.swift b/TalerCommon/HelperViews/SingleAxisGeometryReader.swift diff --git a/TalerCommon/HelperViews/TextFieldAlert.swift b/TalerCommon/HelperViews/TextFieldAlert.swift @@ -0,0 +1,72 @@ +/* + * This file is part of GNU Taler, ©2022-26 Taler Systems S.A. + * See LICENSE.md + */ +/** + * @author Marc Stibane + */ +import SwiftUI + +struct TextFieldAlert: ViewModifier { + @Binding var isPresented: Bool + let title: String + let doneText: String + @Binding var text: String + let placeholder: String + let action: (String) -> Void + func body(content: Content) -> some View { + ZStack(alignment: .center) { + content + .disabled(isPresented) + .accessibilityElement(children: isPresented ? .ignore : .contain) + if isPresented { + VStack { + Text(title) + .talerFont(.headline) + .accessibilityAddTraits(.isHeader) + .accessibilityRemoveTraits(.isStaticText) + .padding() + TextField(placeholder, text: $text).textFieldStyle(.roundedBorder).padding() + Divider() + HStack { + Spacer() + Button(role: .cancel) { + withAnimation { isPresented.toggle() } + } label: { + Text("Cancel") + } + Spacer() + Divider() + Spacer() + Button(doneText) { + action(text) + withAnimation { isPresented.toggle() } + } +// .talerFont(.talerBody) TODO: check + Spacer() + } + } + .accessibility(addTraits: .isModal) + .background(.background) + .frame(width: 300, height: 200) + .cornerRadius(20) + .overlay { + RoundedRectangle(cornerRadius: 20) + .stroke(.quaternary, lineWidth: 1) + } + } + } + } +} + +extension View { + public func textFieldAlert(isPresented: Binding<Bool>, + title: String, + doneText: String, + text: Binding<String>, + placeholder: String = EMPTYSTRING, + action: @escaping (String) -> Void + ) -> some View { + self.modifier(TextFieldAlert(isPresented: isPresented, title: title, doneText: doneText, text: text, placeholder: placeholder, action: action)) + } +} diff --git a/TalerWallet1/Views/HelperViews/TimeView.swift b/TalerCommon/HelperViews/TimeView.swift diff --git a/TalerCommon/HelperViews/TransactionButton.swift b/TalerCommon/HelperViews/TransactionButton.swift @@ -0,0 +1,117 @@ +/* + * This file is part of GNU Taler, ©2022-26 Taler Systems S.A. + * See LICENSE.md + */ +/** + * @author Marc Stibane + */ +import SwiftUI +import taler_swift +import AVFoundation + +struct WarningButton: View { + let warningText: String? + let buttonTitle: String + let buttonIcon: String? + let role: ButtonRole? + @Binding var disabled: Bool + let action: () -> Void + + @AppStorage("shouldShowWarning") var shouldShowWarning: Bool = true + @State private var showAlert: Bool = false + + var body: some View { + Button(//role: role, + action: { + if !disabled { + if shouldShowWarning && (role == .destructive || role == .cancel) { + showAlert = true + } else { + action() + } + } + }) { + HStack(spacing: 20) { + if let buttonIcon { + Image(systemName: buttonIcon) + } + Text(buttonTitle) + } + .frame(maxWidth: .infinity) + .foregroundColor(role == .destructive ? WalletColors().errorColor + : WalletColors().primaryAccent) + } + .talerFont(.title1) + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(disabled) + .alert(warningText ?? EMPTYSTRING, isPresented: $showAlert, actions: { + Button("Cancel", role: .cancel) { + showAlert = false + } + Button(buttonTitle) { + showAlert = false + action() + } + }, message: { Text("This operation cannot be undone") } + ) + } +} +// MARK: - +struct TransactionButton: View { + let transactionId: String + let command: TxAction + let warning: String? + @Binding var didExecute: Bool + let action: (_ transactionId: String, _ viewHandles: Bool) async throws -> Void + + @State private var disabled: Bool = false + @State private var executed: Bool = false + @State private var buttonTitle: String = EMPTYSTRING + + @MainActor + private func doAction() { + disabled = true // don't try this more than once + Task { // runs on MainActor + if let _ = try? await action(transactionId, false) { +// symLog.log("\(executed) \(transactionId)") + executed = true // change button text + didExecute = true + } + } + } + + var body: some View { + let isDestructive = (command == .delete) || (command == .fail) + let isCancel = (command == .abort) + let role: ButtonRole? = isDestructive ? .destructive + : isCancel ? .cancel + : nil + let buttonTitle = executed ? command.localizedActionExecuted + : command.localizedActionTitle + WarningButton(warningText: warning, + buttonTitle: buttonTitle, + buttonIcon: command.localizedActionImage, + role: role, // TODO: WalletColors().errorColor + disabled: $disabled, + action: doAction) + } +} +// MARK: - +#if DEBUG +//struct TransactionButton_Previews: PreviewProvider { +// +// static func action(_ transactionId: String, _ viewHandles: Bool) async throws { +// print(transactionId) +// } +// +// static var previews: some View { +// List { +// TransactionButton(transactionId: "Button pressed", command: .abort, +// warning: "Are you sure you want to abort this transaction?", +// didExecute: <#Binding<Bool>#>, +// action: action) +// } +// } +//} +#endif diff --git a/TalerWallet1/Views/HelperViews/TruncationDetectingText.swift b/TalerCommon/HelperViews/TruncationDetectingText.swift diff --git a/TalerWallet1/Views/HelperViews/AmountRowV.swift b/TalerWallet1/Views/HelperViews/AmountRowV.swift @@ -1,107 +0,0 @@ -/* - * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. - * See LICENSE.md - */ -/** - * @author Marc Stibane - */ -import SwiftUI -import taler_swift - -// Title and Amount -struct AmountRowV: View { - let stack: CallStack? - let title: String - let amount: Amount - let scope: ScopeInfo? - let isNegative: Bool? // show fee with minus (or plus) sign, or no sign if nil - let color: Color - let large: Bool // set to false for QR or IBAN - - var body: some View { - let titleV = Text(title) - .multilineTextAlignment(.leading) - .talerFont(.body) - let amountV = AmountV(stack: stack?.push(), - scope: scope, - amount: amount, - isNegative: isNegative, - strikethrough: false, - large: large) - .foregroundColor(color) - let verticalV = VStack(alignment: .leading) { - titleV - HStack(alignment: .lastTextBaseline) { - Spacer(minLength: 2) - amountV - } - } - Group { - if #available(iOS 16.4, *) { - ViewThatFits(in: .horizontal) { - HStack(alignment: .lastTextBaseline) { - titleV//.border(.orange) - Spacer(minLength: 2) - amountV//.border(.gray) - } - HStack(alignment: .lastTextBaseline) { - titleV//.border(.blue) - .lineLimit(2, reservesSpace: true) - .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 2) - amountV//.border(.gray) - } - verticalV - } - } else { // view for iOS 15 - verticalV - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - .listRowSeparator(.hidden) - } -} -extension AmountRowV { - init(_ title: String, amount: Amount, scope: ScopeInfo?, isNegative: Bool, color: Color) { - self.stack = nil - self.title = title - self.amount = amount - self.scope = scope - self.isNegative = isNegative - self.color = color - self.large = true - } -} - -// MARK: - -fileprivate func talerFromStr(_ from: String) -> Amount { - do { - let amount = try Amount(fromString: from) - return amount - } catch { - return Amount(currency: "Taler", cent: 480) - } -} - -#if DEBUG -@MainActor -fileprivate struct BindingViewContainer: View { - @State private var previewD: CurrencyInfo = CurrencyInfo.zero(DEMOCURRENCY) - var body: some View { - let scope = ScopeInfo.zero(DEMOCURRENCY) - let fee = Amount(currency: DEMOCURRENCY, cent: 20) - AmountRowV("Fee", amount: fee, scope: scope, isNegative: true, color: Color("Outgoing")) - let cents = Amount(currency: DEMOCURRENCY, cent: 480) - AmountRowV("Cents", amount: cents, scope: scope, isNegative: false, color: Color("Incoming")) - let amount = talerFromStr("Taler:4.80") - AmountRowV("Chosen amount to withdraw", amount: amount, scope: scope, isNegative: false, color: Color("Incoming")) - } -} - -#Preview { - List { - BindingViewContainer() - } -} -#endif diff --git a/TalerWallet1/Views/HelperViews/Buttons.swift b/TalerWallet1/Views/HelperViews/Buttons.swift @@ -1,461 +0,0 @@ -/* - * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. - * See LICENSE.md - */ -/** - * @author Marc Stibane - */ -import SwiftUI -import Foundation -import AVFoundation - -extension ShapeStyle where Self == Color { - static var random: Color { - Color( - red: .random(in: 0...1), - green: .random(in: 0...1), - blue: .random(in: 0...1) - ) - } -} - -struct HamburgerButton : View { - let action: () -> Void - - var body: some View { - Button(action: action) { - Image(systemName: HAMBURGER) -// Image(systemName: "sidebar.squares.leading") // 􀱦 - } - .talerFont(.title) - .accessibilityLabel(Text("Main Menu", comment: "a11y")) - } -} - -struct LinkButton: View { - let destination: URL - let hintTitle: String - let buttonTitle: String - let a11yHint: String - let badge: String - - @AppStorage("minimalistic") var minimalistic: Bool = false - - var body: some View { - VStack(alignment: .leading) { - if !minimalistic { // show hint that the user should authorize on bank website - Text(hintTitle) - .fixedSize(horizontal: false, vertical: true) // wrap in scrollview - .multilineTextAlignment(.leading) // otherwise - .listRowSeparator(.hidden) - } - Link(destination: destination) { - HStack(spacing: 8.0) { - Image(systemName: LINK) - Text(buttonTitle) - } - } - .buttonStyle(TalerButtonStyle(type: .prominent, badge: badge)) - .accessibilityHint(a11yHint) - } - } -} - -struct QRButton : View { - let hideTitle: Bool - let action: () -> Void - - @AppStorage("minimalistic") var minimalistic: Bool = false - @State private var showCameraAlert: Bool = false - - private var openSettingsButton: some View { - Button("Open Settings") { - showCameraAlert = false - UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) - } - } - let closingAnnouncement = String(localized: "Closing Camera", comment: "a11y") - - var defaultPriorityAnnouncement = String(localized: "Opening Camera", comment: "a11y") - - var highPriorityAnnouncement: AttributedString { - var highPriorityString = AttributedString(localized: "Camera Active", comment: "a11y") - if #available(iOS 17.0, *) { - highPriorityString.accessibilitySpeechAnnouncementPriority = .high - } - return highPriorityString - } - @MainActor - private func checkCameraAvailable() -> Void { - // Open Camera when QR-Button was tapped - announce(defaultPriorityAnnouncement) - - AVCaptureDevice.requestAccess(for: .video, completionHandler: { (granted: Bool) -> Void in - if granted { - action() - if #available(iOS 17.0, *) { - AccessibilityNotification.Announcement(highPriorityAnnouncement).post() - } else { - let cameraActive = String(localized: "Camera Active", comment: "a11y") - announce(cameraActive) - } - } else { - showCameraAlert = true - } - }) - } - - var body: some View { - let dismissAlertButton = Button("Cancel", role: .cancel) { - announce(closingAnnouncement) - showCameraAlert = false - } - let scanText = String(localized: "Scan QR code", comment: "Button title, a11y") - let qrImage = Image(systemName: QRBUTTON) - let qrText = Text(qrImage) - Button(action: checkCameraAvailable) { - if hideTitle { - qrImage - .resizable() - .scaledToFit() - .foregroundStyle(WalletColors().primaryAccent) - } else if minimalistic { - let width = UIScreen.screenWidth / 7 - qrText.talerFont(.largeTitle) - .padding(.horizontal, width) - } else { - HStack(spacing: 16) { - qrText.talerFont(.title) - Text(scanText) - }.padding(.horizontal) - } - } - .accessibilityLabel(scanText) - .alert("Scanning QR-codes requires access to the camera", - isPresented: $showCameraAlert, - actions: { openSettingsButton - dismissAlertButton }, - message: { Text("Please allow camera access in Settings.") }) // Scanning QR-codes - } -} - -struct DoneButton : View { - let titleStr: String? - let accessibilityLabelStr: String - let action: () -> Void - - var body: some View { - Button(action: action) { - if let titleStr { - Text(titleStr) - } else { - Image(systemName: CHECKMARK) // 􀆅 - } - } - .tint(WalletColors().primaryAccent) - .talerFont(.title) - .accessibilityLabel(accessibilityLabelStr) - } -} - -struct PlusButton : View { - let accessibilityLabelStr: String - let action: () -> Void - - var body: some View { - Button(action: action) { - Image(systemName: PLUS) - } - .tint(WalletColors().primaryAccent) - .talerFont(.title) - .accessibilityLabel(accessibilityLabelStr) - } -} - -@available(iOS 26.0, *) -struct SettingsButton26 : View { - let sortPriority: Double - - var body: some View { - Button { - // will trigger NavigationLink - NotificationCenter.default.post(name: .SettingsAction, object: nil) - } label: { - Label(TalerTab.settings.title, systemImage: TalerTab.settings.sysImg) - .labelStyle(.iconOnly) - } -// .padding() - .buttonStyle(.glass) - .accessibilitySortPriority(sortPriority) - } -} - -struct SettingsButton : View { - let accessibilityLabelStr: String - let action: () -> Void - - var body: some View { - let button = Button(action: action) { - Image(systemName: SETTINGS) - } - .tint(WalletColors().primaryAccent) - .talerFont(.title) - .accessibilityLabel(accessibilityLabelStr) -// if #available(iOS 26.0, *) { -// return button.buttonStyle(.glass) -// } - return button - } -} -struct TalerPasteButton : View { - let accessibilityLabelStr: String - let action: () -> Void - - var body: some View { - let button = Button(action: action) { - Image(systemName: PASTE) - } - .tint(WalletColors().primaryAccent) - .talerFont(.title) - .accessibilityLabel(accessibilityLabelStr) -// if #available(iOS 26.0, *) { -// return button.buttonStyle(.glass) -// } - return button - } -} - -struct ZoomInButton : View { - let accessibilityLabelStr: String - let action: () -> Void - - var body: some View { - Button(action: action) { - Image(ICONNAME_ZOOM_IN, SYSTEM_ZOOM_IN) - } - .tint(WalletColors().primaryAccent) - .talerFont(.title) - .accessibilityLabel(accessibilityLabelStr) - } -} - -struct ZoomOutButton : View { - let accessibilityLabelStr: String - let action: () -> Void - - var body: some View { - Button(action: action) { - Image(ICONNAME_ZOOM_OUT, SYSTEM_ZOOM_OUT) - } - .tint(WalletColors().primaryAccent) - .talerFont(.title) - .accessibilityLabel(accessibilityLabelStr) - } -} - -struct BackButton : View { - let action: () -> Void - - var body: some View { - Button(action: action) { - let name = ICONNAME_INCOMING + ICONNAME_FILL - let sysName = SYSTEM_INCOMING4 + ICONNAME_FILL - Image(name, sysName, fallback: FALLBACK_INCOMING) - } - .tint(WalletColors().primaryAccent) - .talerFont(.largeTitle) - .accessibilityLabel(Text("Back", comment: "a11y")) - } -} - -struct ForwardButton : View { - let enabled: Bool - let action: () -> Void - - var body: some View { - let myAction = { - if enabled { - action() - } - } - Button(action: myAction) { - let imageName = enabled ? ICONNAME_OUTGOING + ICONNAME_FILL - : ICONNAME_OUTGOING - let sysName = enabled ? SYSTEM_OUTGOING4 + ICONNAME_FILL - : SYSTEM_OUTGOING4 - Image(imageName, sysName, fallback: FALLBACK_OUTGOING) - } - .tint(WalletColors().primaryAccent) - .talerFont(.largeTitle) - .accessibilityLabel(Text("Continue", comment: "a11y")) - } -} - -struct ArrowUpButton : View { - let action: () -> Void - - var body: some View { - Button(action: action) { - Image(systemName: ARROW_TOP) // 􀄿 - } - .tint(WalletColors().primaryAccent) - .talerFont(.title2) - .accessibilityLabel(Text("Scroll up", comment: "a11y")) - } -} - -struct ArrowDownButton : View { - let action: () -> Void - - var body: some View { - Button(action: action) { - Image(systemName: ARROW_BOT) // 􀅀 - } - .tint(WalletColors().primaryAccent) - .talerFont(.title2) - .accessibilityLabel(Text("Scroll down", comment: "a11y")) - } -} - -struct ReloadButton : View { - let disabled: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - Image(systemName: RELOAD) // 􀅈 - } - .tint(WalletColors().primaryAccent) - .talerFont(.title) - .accessibilityLabel(Text("Reload", comment: "a11y")) - .disabled(disabled) - } -} - -enum TalerButtonStyleType { - case plain - case bordered - case prominent -} -struct TalerButtonStyle: ButtonStyle { - var type: TalerButtonStyleType = .plain - var dimmed: Bool = false - var narrow: Bool = false - var one: Bool = false - var disabled: Bool = false - var aligned: TextAlignment = .center - var badge: String = EMPTYSTRING - - @Environment(\.colorScheme) private var colorScheme - @Environment(\.colorSchemeContrast) private var colorSchemeContrast - - public func makeBody(configuration: ButtonStyleConfiguration) -> some View { - // configuration.role = type == .prominent ? .primary : .normal Only on macOS - MyBigButton(foreColor: foreColor(type: type, pressed: configuration.isPressed, - scheme: colorScheme, contrast: colorSchemeContrast, disabled: disabled), - backColor: backColor(type: type, pressed: configuration.isPressed, disabled: disabled), - dimmed: dimmed, - configuration: configuration, - disabled: disabled, - narrow: narrow, - one: one, - aligned: aligned, - badge: badge, - scheme: colorScheme, - contrast: colorSchemeContrast) - } - - func foreColor(type: TalerButtonStyleType, - pressed: Bool, - scheme: ColorScheme, - contrast: ColorSchemeContrast, - disabled: Bool) -> Color { - if type == .plain { - return WalletColors().fieldForeground // primary text color - } - return WalletColors().buttonForeColor(pressed: pressed, - disabled: disabled, - scheme: scheme, - contrast: contrast, - prominent: type == .prominent) - } - func backColor(type: TalerButtonStyleType, pressed: Bool, disabled: Bool) -> Color { - if type == .plain && !pressed { - return Color.clear - } - return WalletColors().buttonBackColor(pressed: pressed, - disabled: disabled, - prominent: type == .prominent) - } - - struct BackgroundView: View { - let color: Color - let dimmed: Bool - var body: some View { - RoundedRectangle( - cornerRadius: 15, - style: .continuous - ) - .fill(color) - .opacity(dimmed ? 0.6 : 1.0) - } - } - - struct MyBigButton: View { -// var type: TalerButtonStyleType - let foreColor: Color - let backColor: Color - let dimmed: Bool - let configuration: ButtonStyle.Configuration - let disabled: Bool - let narrow: Bool - let one: Bool - let aligned: TextAlignment - var badge: String - var scheme: ColorScheme - var contrast: ColorSchemeContrast - - var body: some View { - let aligned2: Alignment = (aligned == .center) ? Alignment.center - : (aligned == .leading) ? Alignment.leading - : Alignment.trailing - let hasBadge = !badge.isEmpty - let buttonLabel = configuration.label - .multilineTextAlignment(aligned) - .lineLimit(one ? 1 : 3) - .talerFont(.title3) // narrow ? .title3 : .title2 - .frame(maxWidth: narrow ? nil : .infinity, alignment: aligned2) - .padding(.vertical, 10) - .padding(.horizontal, hasBadge ? 0 : 6) - .foregroundColor(foreColor) - .background(BackgroundView(color: backColor, dimmed: dimmed)) - .contentShape(Rectangle()) // make sure the button can be pressed even if backgroundColor == clear - .scaleEffect(configuration.isPressed ? 0.95 : 1) - .animation(.spring(response: 0.1), value: configuration.isPressed) - .disabled(disabled) - if hasBadge { - let badgeColor: Color = (badge == CONFIRM_BANK) ? WalletColors().confirm - : WalletColors().attention - let badgeV = Image(systemName: badge) - .talerFont(.caption) - HStack(alignment: .top, spacing: 0) { - badgeV.foregroundColor(.clear) - buttonLabel - badgeV.foregroundColor(badgeColor) - } - } else { - buttonLabel - } - } - } -} -// MARK: - -#if DEBUG -fileprivate struct ContentView_Previews: PreviewProvider { - static var previews: some View { - let testButtonTitle = String("Placeholder") - Button(testButtonTitle) {} - .buttonStyle(TalerButtonStyle(type: .bordered, aligned: .trailing)) - } -} -#endif diff --git a/TalerWallet1/Views/HelperViews/CopyShare.swift b/TalerWallet1/Views/HelperViews/CopyShare.swift @@ -1,274 +0,0 @@ -/* - * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. - * See LICENSE.md - */ -/** - * @author Marc Stibane - */ -import UniformTypeIdentifiers -import SwiftUI -import SymLog - -@MainActor -struct FeedbackButton: View { - private let symLog = SymLogV(0) - let title: String? - let image: UIImage? - let isDisabled: Bool - let action: () -> Void - - @EnvironmentObject private var controller: Controller - @State private var scale: CGFloat = 1.0 - - public init(_ title: String? = nil, - image: UIImage? = nil, - disabled: Bool = false, - action: @escaping @MainActor () -> Void) { - self.title = title - self.image = image - self.isDisabled = disabled - self.action = action - } - - private func triggerPulse() { - // First scale down quickly - withAnimation(.easeIn(duration: 0.1)) { - scale = 0.8 - } - // Then bounce back bigger - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - withAnimation(.easeOut(duration: 0.25)) { - scale = 1.15 - } - } - // Finally settle to normal - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { - withAnimation(.easeInOut(duration: 0.25)) { - scale = 1.0 - } - } - } - - var body: some View { - Button(title ?? EMPTYSTRING) { - symLog.log(title ?? EMPTYSTRING) - controller.hapticFeedback(.medium) - action() - triggerPulse() - } - .buttonStyle(TalerButtonStyle(type: .bordered, disabled: isDisabled)) - } -} -// MARK: - -@MainActor -struct CopyButton: View { - private let symLog = SymLogV(0) - let textToCopy: String - @Binding var isCopied: Bool? - let image: UIImage? - let vertical: Bool - let title: String? - - @Environment(\.isEnabled) private var isEnabled: Bool - @EnvironmentObject private var controller: Controller - - @State private var scale: CGFloat = 1.0 - - init(_ textToCopy: String, isCopied: Binding<Bool?>? = nil, - vertical: Bool, image: UIImage? = nil) { - self.textToCopy = textToCopy - self._isCopied = isCopied ?? Binding.constant(nil) - self.image = image - self.vertical = vertical - self.title = nil - } - - init(_ textToCopy: String, isCopied: Binding<Bool?>? = nil, - title: String, image: UIImage? = nil) { - self.textToCopy = textToCopy - self._isCopied = isCopied ?? Binding.constant(nil) - self.image = image - self.vertical = false - self.title = title - } - - func copyAction() -> Void { - symLog.log(textToCopy) - triggerPulse() - controller.hapticFeedback(.medium) - let pasteboard = UIPasteboard.general - if let image { -// pasteboard.image = image - let strItem = [UTType.plainText.identifier : textToCopy] - let imgItem = [UTType.image.identifier : image] - pasteboard.items = [imgItem, strItem] // iOS27 Notes.app crashes if text comes first! - } else { - pasteboard.string = textToCopy - } - if isCopied != nil { - isCopied = true - } - } - - private func triggerPulse() { - // First scale down quickly - withAnimation(.easeIn(duration: 0.1)) { - scale = 0.8 - } - // Then bounce back bigger - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - withAnimation(.easeOut(duration: 0.25)) { - scale = 1.15 - } - } - // Finally settle to normal - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { - withAnimation(.easeInOut(duration: 0.25)) { - scale = 1.0 - } - } - } - - var body: some View { - Button(action: copyAction) { - let image = Image(systemName: COPY1) // 􀉁 - .accessibility(hidden: true) - - if vertical { - VStack { - let shortCopy = String(localized: "Copy.short", defaultValue: "Copy", comment: "5 letters max, else abbreviate") - image - Text(shortCopy) - } - } else { - let longCopy = String(localized: "Copy.long", defaultValue: "Copy", comment: "may be a bit longer") - HStack { - image - Text(title ?? longCopy) - } - } - } - .tint(WalletColors().primaryAccent) - .talerFont(.body) - .scaleEffect(scale) - .disabled(!isEnabled) - } -} -// MARK: - -struct ShareType: Hashable { - let textToShare: String - let image: UIImage? -} -// MARK: - -@MainActor -struct ShareButton: View { - private let symLog = SymLogV(0) - let textToShare: String - let image: UIImage? - let title: String - - @State private var scale: CGFloat = 1.0 - - init(_ textToShare: String, image: UIImage? = nil) { - self.textToShare = textToShare - self.image = image - self.title = String(localized: "Share") - } - init(_ textToShare: String, title: String, image: UIImage? = nil) { - self.textToShare = textToShare - self.image = image - self.title = title - } - - @Environment(\.isEnabled) private var isEnabled: Bool - @EnvironmentObject private var controller: Controller - - @MainActor - func dismissAndPost() { - dismissTop() - let shareType = ShareType(textToShare: textToShare, image: image) - let userInfo = [NOTIFICATIONSHARE: shareType] - NotificationCenter.default.post(name: .ShareAction, object: nil, userInfo: userInfo) // will trigger NavigationLink - } - - func shareAction() -> Void { - symLog.log(textToShare) - controller.hapticFeedback(.soft) - Task { - // First scale down quickly - withAnimation(.easeIn(duration: 0.1)) { - scale = 0.8 - } - // Then bounce back bigger - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - withAnimation(.easeOut(duration: 0.25)) { - scale = 1.15 - } - dismissAndPost() - } - // Finally settle to normal - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { - withAnimation(.easeInOut(duration: 0.25)) { - scale = 1.0 - } - } - } - } - - var body: some View { - Button(action: shareAction) { - HStack { - Image(systemName: SHARE) // 􀈂 - .accessibility(hidden: true) - Text(title) - } - } - .tint(WalletColors().primaryAccent) - .talerFont(.body) - .scaleEffect(scale) - .disabled(!isEnabled) - } -} -// MARK: - -struct CopyShare: View { - @Environment(\.isEnabled) private var isEnabled: Bool - - let textToCopy: String - let image: UIImage? - - init(_ string: String, image: UIImage? = nil) { - self.textToCopy = string - self.image = image - } - - var body: some View { - let copyB = CopyButton(textToCopy, vertical: false, image: image) - .buttonStyle(TalerButtonStyle(type: .bordered)) - let share = ShareButton(textToCopy, image: image) - .buttonStyle(TalerButtonStyle(type: .bordered)) - - let vLayout = VStack { - copyB - share - } - - if #available(iOS 16.0, *) { - let hLayout = HStack(spacing: HSPACING) { - copyB - share - } - ViewThatFits(in: .horizontal) { - hLayout - vLayout - } - } else { - vLayout - } - } -} -// MARK: - -struct CopyShare_Previews: PreviewProvider { - static var previews: some View { - CopyShare("Hallö", image: nil) - } -} diff --git a/TalerWallet1/Views/HelperViews/CurrencyInputView.swift b/TalerWallet1/Views/HelperViews/CurrencyInputView.swift @@ -1,269 +0,0 @@ -/* - * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. - * See LICENSE.md - */ -/** - * @author Marc Stibane - */ -import SwiftUI -import taler_swift - -fileprivate let replaceable = 500 -fileprivate let shortcutValues = [5000,2500,1000] // TODO: adapt for ¥ - -struct ShortcutButton: View { - let scope: ScopeInfo? - let currency: String - let currencyField: CurrencyField - let shortcut: Int - let available: Amount? // disable if available < value - let action: (Int, CurrencyField) -> Void - - func makeButton(with newShortcut: Int) -> ShortcutButton { - ShortcutButton(scope: scope, - currency: currency, - currencyField: currencyField, - shortcut: newShortcut, - available: available, - action: action) - } - - func isDisabled(shortie: Amount) -> Bool { - if let available { - return available.value < shortie.value - } - return false - } - - var body: some View { -#if PRINT_CHANGES - let _ = Self._printChanges() -// let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear -#endif - let shortie = Amount(currency: currency, cent: UInt64(shortcut)) // TODO: adapt for ¥ - let title = shortie.formatted(scope, isNegative: false) - let shortcutLabel = String(localized: "Shortcut", comment: "a11y: $50,$25,$10,$5 shortcut buttons") - let a11yLabel = "\(shortcutLabel) \(title.1)" - Button(action: { action(shortcut, currencyField)} ) { - Text(title.0) - .lineLimit(1) - .talerFont(.callout) - } -// .frame(maxWidth: .infinity) - .disabled(isDisabled(shortie: shortie)) - .buttonStyle(.bordered) - .accessibilityLabel(a11yLabel) - } -} -// MARK: - -struct CurrencyInputView: View { - let scope: ScopeInfo? - @Binding var amount: Amount // the `value´ - let amountLastUsed: Amount - let available: Amount? - let title: String? - let a11yTitle: String - let shortcutAction: ((_ amount: Amount) -> Void)? - - @EnvironmentObject private var controller: Controller - - @State private var hasBeenShown = false - @State private var useShortcut = 0 - // `body´ builds a new CurrencyField each time, but the text field is created only - // once - keep the handle to it here, where it survives the re-evaluations - @State private var fieldHandle = CurrencyFieldHandle() - - @MainActor - func action(shortcut: Int, currencyField: CurrencyField) { - let shortie = Amount(currency: amount.currencyStr, cent: UInt64(shortcut)) // TODO: adapt for ¥ - if let shortcutAction { - shortcutAction(shortie) - } else { - useShortcut = shortcut - currencyField.updateText(amount: shortie) - amount = shortie - currencyField.resignFirstResponder() - } - } - - @MainActor - func shortcut(for value: Int,_ currencyField: CurrencyField) -> ShortcutButton { - var shortcut = value - if value == replaceable { - if !amountLastUsed.isZero { - let lastUsedD = amountLastUsed.value - let lastUsedI = lround(lastUsedD * 100) - if !shortcutValues.contains(lastUsedI) { - shortcut = lastUsedI - } } } - return ShortcutButton(scope: scope, - currency: amount.currencyStr, - currencyField: currencyField, - shortcut: shortcut, - available: available, - action: action) - } - - @MainActor - func shortcuts(_ currencyField: CurrencyField, _ currencyInfo: CurrencyInfo) -> [ShortcutButton] { - var buttons: [ShortcutButton] = [] - if let commonAmounts = currencyInfo.commonAmounts { - buttons = commonAmounts.prefix(4).map { amount in - shortcut(for: Int(amount.centValue), currencyField) - } - } else { - buttons = shortcutValues.map { value in - shortcut(for: value, currencyField) - } - buttons.append(shortcut(for: replaceable, currencyField)) - } - return buttons - } - - func availableString(_ availableStr: String) -> String { - String(localized: "Available for transfer: \(availableStr)") - } - - var a11yLabel: String { // format currency for a11y - availableString(available?.readableDescription ?? String(localized: "unknown")) - } - - func heading() -> (String, String)? { - if let title { - return (title, title) - } - if let available { - let formatted = available.formatted(scope, isNegative: false) - return (availableString(formatted.0), availableString(formatted.1)) - } - return nil - } - - func currencyInfo() -> CurrencyInfo { - if let scope { - return controller.info(for: scope, controller.currencyTicker) - } else { - return controller.info2(for: amount.currencyStr, controller.currencyTicker) - } - } - - var body: some View { -#if PRINT_CHANGES - let _ = Self._printChanges() -// let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear -#endif - let currencyInfo = currencyInfo() - let currencyField = CurrencyField(currencyInfo, amount: $amount, handle: fieldHandle) - VStack (alignment: .center) { // center shortcut buttons - if let heading = heading() { - Text(heading.0) - .padding(.horizontal, 4) - .padding(.top) - .frame(maxWidth: .infinity, alignment: title != nil ? .leading : .trailing) - .talerFont(.title2) - .accessibilityLabel(heading.1) - .padding(.bottom, -6) - } - currencyField - .accessibilityLabel(a11yTitle) - .frame(maxWidth: .infinity, alignment: .trailing) - .foregroundColor(WalletColors().fieldForeground) // text color -// .background(WalletColors().fieldBackground) // problem: white corners - .talerFont(.title2) - .textFieldStyle(.roundedBorder) - .onTapGesture { - if useShortcut != 0 { - amount = Amount.zero(currency: amount.currencyStr) - useShortcut = 0 - } - } - if #available(iOS 16.4, *) { - let shortcuts = shortcuts(currencyField, currencyInfo) - ViewThatFits(in: .horizontal) { - HStack { - ForEach(shortcuts, id: \.shortcut) { - $0.accessibilityAddTraits($0.shortcut == useShortcut ? .isSelected : []) - } - } - VStack { - let count = shortcuts.count - let half = count / 2 - HStack { - Spacer() - ForEach(0..<half, id: \.self) { index in - let thisShortcut = shortcuts[index] - thisShortcut - .accessibilityAddTraits(thisShortcut.shortcut == useShortcut ? .isSelected : []) - Spacer() - } - } - HStack { - Spacer() - ForEach(half..<count, id: \.self) { index in - let thisShortcut = shortcuts[index] - thisShortcut - .accessibilityAddTraits(thisShortcut.shortcut == useShortcut ? .isSelected : []) - Spacer() - } - } - } - VStack { - ForEach(shortcuts, id: \.shortcut) { - $0.accessibilityAddTraits($0.shortcut == useShortcut ? .isSelected : []) - } - } - } - .padding(.vertical, 6) - } // iOS 16+ only - }.onAppear { // make CurrencyField show the keyboard after 0.4 seconds -#if OIM - let oimModeActive = controller.oimModeActive -#else - let oimModeActive = false -#endif - if hasBeenShown { -// print("❗️Yikes: CurrencyInputView hasBeenShown") - } else if !UIAccessibility.isVoiceOverRunning && !oimModeActive { -// print("❗️CurrencyInputView❗️") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { - hasBeenShown = true - if !oimModeActive { - if !currencyField.becomeFirstResponder() { - print("❗️Yikes❗️ cannot becomeFirstResponder") - } - } - } - } - }.onDisappear { - currencyField.resignFirstResponder() - hasBeenShown = false - } -#if OIM - .onChange(of: controller.oimModeActive) {_ in - currencyField.resignFirstResponder() - } -#endif - } -} -// MARK: - -#if DEBUG -//fileprivate struct Previews: PreviewProvider { -// @MainActor -// struct StateContainer: View { -// @State var amountToPreview = Amount(currency: LONGCURRENCY, cent: 0) -// @State var amountLastUsed = Amount(currency: LONGCURRENCY, cent: 170) -// @State private var previewL: CurrencyInfo = CurrencyInfo.zero(LONGCURRENCY) -// var body: some View { -// CurrencyInputView(amount: $amountToPreview, -// scope: <#ScopeInfo#>, -// amountLastUsed: amountLastUsed, -// available: Amount(currency: LONGCURRENCY, cent: 2000), -// title: "Amount to withdraw:", -// shortcutAction: nil) -// } -// } -// static var previews: some View { -// StateContainer() -// } -//} -#endif diff --git a/TalerWallet1/Views/HelperViews/IconBadge.swift b/TalerWallet1/Views/HelperViews/IconBadge.swift @@ -1,214 +0,0 @@ -/* - * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. - * See LICENSE.md - */ -/** - * @author Marc Stibane - */ -import SwiftUI - -struct PendingIconBadge: View { - let foreColor:Color - let done: Bool - let incoming: Bool - let shouldConfirm: Bool - let needsKYC: Bool - - var body: some View { - let image = incoming && done ? Image(systemName: DONE_INCOMING) // "plus.circle.fill" - : incoming ? Image(systemName: PENDING_INCOMING) // "plus" - // since outgoing money already left the wallet, show DONE_ and not PENDING_OUTGOING - : Image(systemName: DONE_OUTGOING) // "minus.circle" - IconBadge(image: image, - done: false, - foreColor: foreColor, - shouldConfirm: shouldConfirm, - needsKYC: needsKYC, - phase: 0, - firstImage: nil, - secondImage: nil, - wideIcon: nil) - } -} -// MARK: - -struct TransactionIconBadge: View { - var type: TransactionType - var foreColor: Color - let done: Bool - let incoming: Bool - let shouldConfirm: Bool - let needsKYC: Bool - let imageBase64: String? - - @State private var image: Image? = nil - - init(type: TransactionType, foreColor: Color, done: Bool, incoming: Bool, - shouldConfirm: Bool, needsKYC: Bool, imageBase64: String? = nil - ) { - self.type = type - self.foreColor = foreColor - self.done = done - self.incoming = incoming - self.shouldConfirm = shouldConfirm - self.needsKYC = needsKYC - self.imageBase64 = imageBase64 - } - - init(from transaction: TalerTransaction, isDark: Bool, _ increasedContrast: Bool, - imageBase64: String? = nil - ) { - let common = transaction.common - self.type = common.type - let done = transaction.isDone - self.done = done - let incoming = common.isIncoming - self.incoming = incoming - let needsKYC = transaction.isPendingKYC || transaction.isPendingKYCauth - self.needsKYC = needsKYC - self.shouldConfirm = transaction.shouldConfirm - let pending = transaction.isPending || transaction.common.isFinalizing - self.foreColor = .primary - - let doneOrPending = done || pending - let isZero = common.amountEffective.isZero - let refreshZero = common.type.isRefresh && isZero - let textColor = doneOrPending ? .primary - : isDark ? .secondary - : increasedContrast ? Color(.darkGray) - : .secondary // Color(.tertiaryLabel) - let foreColor = refreshZero ? textColor - : pending ? WalletColors().pendingColor(incoming) - : done ? WalletColors().transactionColor(incoming) - : WalletColors().uncompletedColor - self.foreColor = foreColor - self.imageBase64 = imageBase64 - } - - var body: some View { - let badge = IconBadge(image: type.icon(done), - done: true, - foreColor: foreColor, - shouldConfirm: shouldConfirm, - needsKYC: needsKYC, - phase: 0, - firstImage: nil, - secondImage: nil, - wideIcon: TransactionType.refund.icon()) - // "arrowshape.turn.up.backward" is wider than all others - if let imageBase64 { - if let image { - image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxHeight: 40) - } else { - badge - .opacity(0.3) - .task { - image = Image(imageBase64: imageBase64) - } - } - } else { - badge - } - } -} -// MARK: - -struct ButtonIconBadge: View { - let type: TransactionType - let phase: Int - let foreColor:Color - let done: Bool - - var body: some View { - let isSend = type.isSendCoins - let isRcve = type.isSendInvoice - let isDepo = type.isDeposit - let isWthd = type.isWithdrawal - let left = ICONNAME_PERSON_LEFT + ICONNAME_FILL - let right = ICONNAME_PERSON_RIGHT - let bottom = ICONNAME_PERSON_BOTTOM + ICONNAME_FILL - let top = ICONNAME_BANK - let firstImage = isSend ? Image(left) - : isRcve ? Image(right) - : isDepo ? Image(bottom) - : isWthd ? Image(top) : nil - let secondImage = isSend ? Image(right) - : isRcve ? Image(left) - : isDepo ? Image(top) - : isWthd ? Image(bottom) : nil - IconBadge(image: type.icon(done), - done: true, - foreColor: foreColor, - shouldConfirm: false, - needsKYC: false, - phase: phase, - firstImage: firstImage, - secondImage: secondImage, - wideIcon: TransactionType.peerPushDebit.icon()) - // button is send/receive/withdraw/deposit, never payment or refund - } -} -// MARK: - -struct IconBadge: View { - let image: Image - let done: Bool - let foreColor:Color - let shouldConfirm: Bool - let needsKYC: Bool - let phase: Int - let firstImage: Image? - let secondImage: Image? - let wideIcon: Image? // cheating: ZStack with widest icon to ensure all have the same width - // TODO: EqualIconWidth... - - @ScaledMetric var spacing = 6 // relative to fontSize - @State private var showFirst = false - @State private var showSecond = false - @State private var showImage = true - - var body: some View { -// let _ = Self._printChanges() - HStack(alignment: .top, spacing: -spacing) { - ZStack { - if let wideIcon { - wideIcon.foregroundColor(.clear) - } - if let firstImage, let secondImage { - let duration = 0.66 - ZStack { - image.opacity(showImage ? 1 : 0) - .animation(.easeInOut(duration: duration), value: showImage) - firstImage.opacity(showFirst ? 1 : 0) - .animation(.easeOut(duration: duration), value: showFirst) - secondImage.opacity(showSecond ? 1 : 0) - .animation(.easeIn(duration: duration), value: showSecond) - } .foregroundColor(foreColor) - .onChange(of: phase) { phaseVal in - switch phaseVal { - case 0, 4: showFirst = false; showSecond = false; showImage = true - case 2, 3: showFirst = true; showSecond = false; showImage = false - case 5, 6: showFirst = false; showSecond = true; showImage = false - default: showFirst = false; showSecond = false; showImage = false - } - } - } else { - image.foregroundColor(foreColor) - } - } - // ZStack centers the main icon, so the badge will always be at the same position - let badgeName = needsKYC ? NEEDS_KYC - : CONFIRM_BANK - Image(systemName: badgeName) - .talerFont(.badge) - .foregroundColor(needsKYC ? WalletColors().attention - : shouldConfirm ? WalletColors().confirm - : .clear) - .padding(.top, -2) - }.accessibilityHidden(true) - } -} -// MARK: - -//#Preview { -// IconBadge() -//} diff --git a/TalerWallet1/Views/HelperViews/LaunchAnimationView.swift b/TalerWallet1/Views/HelperViews/LaunchAnimationView.swift @@ -1,79 +0,0 @@ -/* - * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. - * See LICENSE.md - */ -/** - * @author Marc Stibane - */ -import SwiftUI - -struct LaunchAnimationView: View { - @State private var rotationEnabled = true - var body: some View { - ZStack { - Color(.systemGray3).ignoresSafeArea() - RotatingTaler(size: (350 < UIScreen.screenWidth) ? 200 : 250, - progress: true, - once: true, - rotationEnabled: $rotationEnabled) - .accessibilityLabel(Text("Progress indicator", comment: "a11y")) - } - } -} -// MARK: - -struct RotatingTaler: View { - let size: CGFloat - let progress: Bool - let once: Bool - - @Binding var rotationEnabled: Bool - @State private var rotationDirection = false - - private let animationTimer = Timer - .publish(every: 1.5, on: .current, in: .common) - .autoconnect() - - var body: some View { - let image = Image(TALER_LOGO) - .resizable() - .scaledToFit() - .frame(width: size, height: size) - .padding(10) - .accessibilityLabel(progress ? Text("In progress", comment: "a11y") - : Text("Taler Logo", comment: "a11y")) // decorative logo - with button function - .rotationEffect(rotationDirection ? Angle(degrees: 0) : Angle(degrees: once ? 900 : 720)) - .onReceive(animationTimer) { timerValue in -// print("Timer: \(timerValue), rotationDirection: \(rotationDirection)") - if rotationEnabled { - if !once { - withAnimation(.easeInOut(duration: 1.5)) { - rotationDirection.toggle() - } - } - } - } - .task { - if once { - withAnimation(.easeInOut(duration: 2.0)) { - rotationDirection.toggle() - } - } - } -// if #available(iOS 26.0, *) { -// image -// .glassEffect(.clear) -// } else { - image - .background { - Capsule() - .fill(Color(.systemGray6).opacity(0.7)) - } -// } - } -} -// MARK: - -struct LaunchAnimationView_Previews: PreviewProvider { - static var previews: some View { - LaunchAnimationView() - } -} diff --git a/TalerWallet1/Views/HelperViews/NavLink.swift b/TalerWallet1/Views/HelperViews/NavLink.swift @@ -1,59 +0,0 @@ -/* - * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. - * See LICENSE.md - */ -/** - * @author Marc Stibane - */ - -import SwiftUI - -/// invisible NavigationLink triggered by a Bool or Int? -/// call either like this -/// .background( NavLink($buttonSelected) { destination } ) -/// or -/// let actions = Group { -/// NavLink(1, $actionSelected) { dest1 } -/// NavLink(2, $actionSelected) { dest2 } -/// } -/// and then -/// .background(actions) - - -struct NavLink <Content : View> : View { - let tag: Int? - @Binding var selection: Int? - @Binding var isActive: Bool - let content: Content - - init(_ tag: Int, - _ selection: Binding<Int?>, - _ isActive: Binding<Bool> = .constant(false), - @ViewBuilder contentBuilder: () -> Content - ) { - self.tag = tag - self._selection = selection - self.content = contentBuilder() - self._isActive = isActive - } - - init(_ isActive: Binding<Bool>, - _ selection: Binding<Int?> = .constant(nil), - @ViewBuilder contentBuilder: () -> Content - ) { - self.tag = nil - self._selection = selection - self.content = contentBuilder() - self._isActive = isActive - } - - var body: some View { - if let tag { // actions: $tabBarModel.actionSelected will hide the tabBar - NavigationLink(destination: content, tag: tag, selection: $selection) - { EmptyView() }.frame(width: 0).opacity(0).hidden() - } else { // shortcuts, AddButton - NavigationLink(destination: content, isActive: $isActive) - { EmptyView() }.frame(width: 0).opacity(0).hidden() - } - } -} diff --git a/TalerWallet1/Views/HelperViews/TextFieldAlert.swift b/TalerWallet1/Views/HelperViews/TextFieldAlert.swift @@ -1,72 +0,0 @@ -/* - * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. - * See LICENSE.md - */ -/** - * @author Marc Stibane - */ -import SwiftUI - -struct TextFieldAlert: ViewModifier { - @Binding var isPresented: Bool - let title: String - let doneText: String - @Binding var text: String - let placeholder: String - let action: (String) -> Void - func body(content: Content) -> some View { - ZStack(alignment: .center) { - content - .disabled(isPresented) - .accessibilityElement(children: isPresented ? .ignore : .contain) - if isPresented { - VStack { - Text(title) - .talerFont(.headline) - .accessibilityAddTraits(.isHeader) - .accessibilityRemoveTraits(.isStaticText) - .padding() - TextField(placeholder, text: $text).textFieldStyle(.roundedBorder).padding() - Divider() - HStack { - Spacer() - Button(role: .cancel) { - withAnimation { isPresented.toggle() } - } label: { - Text("Cancel") - } - Spacer() - Divider() - Spacer() - Button(doneText) { - action(text) - withAnimation { isPresented.toggle() } - } -// .talerFont(.talerBody) TODO: check - Spacer() - } - } - .accessibility(addTraits: .isModal) - .background(.background) - .frame(width: 300, height: 200) - .cornerRadius(20) - .overlay { - RoundedRectangle(cornerRadius: 20) - .stroke(.quaternary, lineWidth: 1) - } - } - } - } -} - -extension View { - public func textFieldAlert(isPresented: Binding<Bool>, - title: String, - doneText: String, - text: Binding<String>, - placeholder: String = EMPTYSTRING, - action: @escaping (String) -> Void - ) -> some View { - self.modifier(TextFieldAlert(isPresented: isPresented, title: title, doneText: doneText, text: text, placeholder: placeholder, action: action)) - } -} diff --git a/TalerWallet1/Views/HelperViews/TransactionButton.swift b/TalerWallet1/Views/HelperViews/TransactionButton.swift @@ -1,117 +0,0 @@ -/* - * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. - * See LICENSE.md - */ -/** - * @author Marc Stibane - */ -import SwiftUI -import taler_swift -import AVFoundation - -struct WarningButton: View { - let warningText: String? - let buttonTitle: String - let buttonIcon: String? - let role: ButtonRole? - @Binding var disabled: Bool - let action: () -> Void - - @AppStorage("shouldShowWarning") var shouldShowWarning: Bool = true - @State private var showAlert: Bool = false - - var body: some View { - Button(//role: role, - action: { - if !disabled { - if shouldShowWarning && (role == .destructive || role == .cancel) { - showAlert = true - } else { - action() - } - } - }) { - HStack(spacing: 20) { - if let buttonIcon { - Image(systemName: buttonIcon) - } - Text(buttonTitle) - } - .frame(maxWidth: .infinity) - .foregroundColor(role == .destructive ? WalletColors().errorColor - : WalletColors().primaryAccent) - } - .talerFont(.title1) - .buttonStyle(.bordered) - .controlSize(.large) - .disabled(disabled) - .alert(warningText ?? EMPTYSTRING, isPresented: $showAlert, actions: { - Button("Cancel", role: .cancel) { - showAlert = false - } - Button(buttonTitle) { - showAlert = false - action() - } - }, message: { Text("This operation cannot be undone") } - ) - } -} -// MARK: - -struct TransactionButton: View { - let transactionId: String - let command: TxAction - let warning: String? - @Binding var didExecute: Bool - let action: (_ transactionId: String, _ viewHandles: Bool) async throws -> Void - - @State private var disabled: Bool = false - @State private var executed: Bool = false - @State private var buttonTitle: String = EMPTYSTRING - - @MainActor - private func doAction() { - disabled = true // don't try this more than once - Task { // runs on MainActor - if let _ = try? await action(transactionId, false) { -// symLog.log("\(executed) \(transactionId)") - executed = true // change button text - didExecute = true - } - } - } - - var body: some View { - let isDestructive = (command == .delete) || (command == .fail) - let isCancel = (command == .abort) - let role: ButtonRole? = isDestructive ? .destructive - : isCancel ? .cancel - : nil - let buttonTitle = executed ? command.localizedActionExecuted - : command.localizedActionTitle - WarningButton(warningText: warning, - buttonTitle: buttonTitle, - buttonIcon: command.localizedActionImage, - role: role, // TODO: WalletColors().errorColor - disabled: $disabled, - action: doAction) - } -} -// MARK: - -#if DEBUG -//struct TransactionButton_Previews: PreviewProvider { -// -// static func action(_ transactionId: String, _ viewHandles: Bool) async throws { -// print(transactionId) -// } -// -// static var previews: some View { -// List { -// TransactionButton(transactionId: "Button pressed", command: .abort, -// warning: "Are you sure you want to abort this transaction?", -// didExecute: <#Binding<Bool>#>, -// action: action) -// } -// } -//} -#endif