taler-ios

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

CopyShare.swift (12318B)


      1 /*
      2  * This file is part of GNU Taler, ©2022-26 Taler Systems S.A.
      3  * See LICENSE.md
      4  */
      5 /**
      6  * @author Marc Stibane
      7  */
      8 import UniformTypeIdentifiers
      9 import SwiftUI
     10 import SymLog
     11 
     12 @MainActor
     13 struct FeedbackButton: View {
     14     private let symLog = SymLogV(0)
     15     let title: String?
     16     let image: UIImage?
     17     let isDisabled: Bool
     18     let haptic: Bool
     19     let action: () -> Void
     20 
     21     @EnvironmentObject private var controller: Controller
     22     @State private var scale: CGFloat = 1.0
     23 
     24     public init(_ title: String? = nil,
     25                   image: UIImage? = nil,
     26                disabled: Bool = false,
     27                  haptic: Bool = true,
     28                  action: @escaping @MainActor () -> Void) {
     29         self.title = title
     30         self.image = image
     31         self.isDisabled = disabled
     32         self.haptic = haptic
     33         self.action = action
     34     }
     35 
     36     private func triggerPulse() {
     37         // First scale down quickly
     38         withAnimation(.easeIn(duration: 0.1)) {
     39             scale = 0.8
     40         }
     41         // Then bounce back bigger
     42         DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
     43             withAnimation(.easeOut(duration: 0.25)) {
     44                 scale = 1.15
     45             }
     46         }
     47         // Finally settle to normal
     48         DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
     49             withAnimation(.easeInOut(duration: 0.25)) {
     50                 scale = 1.0
     51             }
     52         }
     53     }
     54 
     55     var body: some View {
     56         Button(title ?? EMPTYSTRING) {
     57             symLog.log(title ?? EMPTYSTRING)
     58             if haptic {
     59                 controller.hapticFeedback(.medium)
     60             }
     61             action()
     62             triggerPulse()
     63         }
     64             .buttonStyle(TalerButtonStyle(type: .bordered, disabled: isDisabled))
     65     }
     66 }
     67 // MARK: -
     68 @MainActor
     69 struct CopyButton: View {
     70     private let symLog = SymLogV(0)
     71     let textToCopy: String
     72     @Binding var isCopied: Bool?
     73     let image: UIImage?
     74     let vertical: Bool
     75     let title: String?
     76     let a11yID: String?                     // unique ID for UI-testing
     77 
     78     @Environment(\.isEnabled) private var isEnabled: Bool
     79     @EnvironmentObject private var controller: Controller
     80 
     81     @State private var scale: CGFloat = 1.0
     82 
     83     init(_ textToCopy: String, isCopied: Binding<Bool?>? = nil,
     84              vertical: Bool, image: UIImage? = nil, a11yID: String? = nil) {
     85         self.textToCopy = textToCopy
     86         self._isCopied = isCopied ?? Binding.constant(nil)
     87         self.image = image
     88         self.vertical = vertical
     89         self.title = nil
     90         self.a11yID = a11yID
     91     }
     92 
     93     init(_ textToCopy: String, isCopied: Binding<Bool?>? = nil,
     94          title: String, image: UIImage? = nil, a11yID: String? = nil) {
     95         self.textToCopy = textToCopy
     96         self._isCopied = isCopied ?? Binding.constant(nil)
     97         self.image = image
     98         self.vertical = false
     99         self.title = title
    100         self.a11yID = a11yID
    101     }
    102 
    103     func copyAction() -> Void {
    104         symLog.log(textToCopy)
    105         triggerPulse()
    106         controller.hapticFeedback(.medium)
    107         let pasteboard = UIPasteboard.general
    108         if let image {
    109 //            pasteboard.image = image
    110             let strItem = [UTType.plainText.identifier : textToCopy]
    111             let imgItem = [UTType.image.identifier : image]
    112             pasteboard.items = [imgItem, strItem]       // iOS27 Notes.app crashes if text comes first!
    113         } else {
    114             pasteboard.string = textToCopy
    115         }
    116         if isCopied != nil {
    117             isCopied = true
    118         }
    119     }
    120 
    121     private func triggerPulse() {
    122         // First scale down quickly
    123         withAnimation(.easeIn(duration: 0.1)) {
    124             scale = 0.8
    125         }
    126         // Then bounce back bigger
    127         DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
    128             withAnimation(.easeOut(duration: 0.25)) {
    129                 scale = 1.15
    130             }
    131         }
    132         // Finally settle to normal
    133         DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
    134             withAnimation(.easeInOut(duration: 0.25)) {
    135                 scale = 1.0
    136             }
    137         }
    138     }
    139 
    140     var body: some View {
    141         Button(action: copyAction) {
    142             let image = Image(systemName: COPY1)    // 􀉁
    143                 .accessibility(hidden: true)
    144 
    145             if vertical {
    146                 VStack {
    147                     let shortCopy = String(localized: "Copy.short", defaultValue: "Copy", comment: "5 letters max, else abbreviate")
    148                     image
    149                     Text(shortCopy)
    150                 }
    151             } else {
    152                 let longCopy = String(localized: "Copy.long", defaultValue: "Copy", comment: "may be a bit longer")
    153                 HStack {
    154                     image
    155                     Text(title ?? longCopy)
    156                 }
    157             }
    158         }
    159         .tint(WalletColors().primaryAccent)
    160         .talerFont(.body)
    161         .scaleEffect(scale)
    162         .disabled(!isEnabled)
    163         .accessibilityIdentifier(a11yID ?? A11yID.copyButton)
    164     }
    165 }
    166 // MARK: -
    167 struct ShareType: Hashable {
    168     let textToShare: String
    169     let image: UIImage?
    170 }
    171 // MARK: -
    172 /// The Share function can NOT be called from a sheet.
    173 ///  Thus we need to know whether we were called from the .sheet view hierarchy
    174 ///  If yes, then we must first dismiss the sheet before posting the .ShareAction notification
    175 extension EnvironmentValues {
    176     @Entry var fromSheet: Bool = false
    177 }
    178 // MARK: -
    179 @MainActor
    180 struct ShareButton: View {
    181     private let symLog = SymLogV(0)
    182     let textToShare: String
    183     let image: UIImage?
    184     let title: String
    185     let a11yID: String?
    186 
    187     init(_ textToShare: String, image: UIImage? = nil, a11yID: String? = nil) {
    188         self.textToShare = textToShare
    189         self.image = image
    190         self.title = String(localized: "Share")
    191         self.a11yID = a11yID
    192     }
    193     init(_ textToShare: String, title: String, image: UIImage? = nil,
    194          a11yID: String? = nil) {
    195         self.textToShare = textToShare
    196         self.image = image
    197         self.title = title
    198         self.a11yID = a11yID
    199     }
    200 
    201     @Environment(\.fromSheet) private var fromSheet
    202     @Environment(\.isEnabled) private var isEnabled: Bool
    203     @EnvironmentObject private var controller: Controller
    204 
    205     @State private var scale: CGFloat = 1.0
    206 
    207     @MainActor
    208     func dismissAndPost() {
    209         if fromSheet {
    210             dismissTop()
    211         }
    212         let shareType = ShareType(textToShare: textToShare, image: image)
    213         let userInfo = [NOTIFICATIONSHARE: shareType]
    214         NotificationCenter.default.post(name: .ShareAction, object: nil, userInfo: userInfo)                // will trigger NavigationLink
    215     }
    216 
    217     func shareAction() -> Void {
    218         symLog.log(textToShare)
    219         controller.hapticFeedback(.soft)
    220         Task {
    221             // First scale down quickly
    222             withAnimation(.easeIn(duration: 0.1)) {
    223                 scale = 0.8
    224             }
    225             // Then bounce back bigger
    226             DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
    227                 withAnimation(.easeOut(duration: 0.25)) {
    228                     scale = 1.15
    229                 }
    230                 dismissAndPost()
    231             }
    232             // Finally settle to normal
    233             DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
    234                 withAnimation(.easeInOut(duration: 0.25)) {
    235                     scale = 1.0
    236                 }
    237             }
    238         }
    239     }
    240 
    241     var body: some View {
    242         Button(action: shareAction) {
    243             HStack {
    244                 Image(systemName: SHARE)        // 􀈂
    245                     .accessibility(hidden: true)
    246                 Text(title)
    247             }
    248         }
    249         .tint(WalletColors().primaryAccent)
    250         .talerFont(.body)
    251         .scaleEffect(scale)
    252         .disabled(!isEnabled)
    253         .accessibilityIdentifier(a11yID ?? A11yID.shareButton)
    254     }
    255 }
    256 // MARK: -
    257 @MainActor
    258 struct ContactsButton: View {
    259     private let symLog = SymLogV(0)
    260     let stack: CallStack
    261     let talerUri: String?
    262     let subject: String?
    263     let title: String
    264 
    265     init(_ stack: CallStack, talerUri: String?, subject: String? = nil, title: String? = nil) {
    266         self.stack = stack
    267         self.talerUri = talerUri
    268         self.title = title ?? String(localized: "Contacts")
    269         self.subject = subject
    270     }
    271 
    272     @Environment(\.isEnabled) private var isEnabled: Bool
    273     @EnvironmentObject private var controller: Controller
    274 
    275     @State private var scale: CGFloat = 1.0
    276     @State private var showContacts: Bool = false
    277 
    278     func shareAction() -> Void {
    279         symLog.log(talerUri)
    280         controller.hapticFeedback(.soft)
    281         Task {
    282             // First scale down quickly
    283             withAnimation(.easeIn(duration: 0.1)) {
    284                 scale = 0.8
    285             }
    286             // Then bounce back bigger
    287             DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
    288                 withAnimation(.easeOut(duration: 0.25)) {
    289                     scale = 1.15
    290                 }
    291                 showContacts = true
    292             }
    293             // Finally settle to normal
    294             DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
    295                 withAnimation(.easeInOut(duration: 0.25)) {
    296                     scale = 1.0
    297                 }
    298             }
    299         }
    300     }
    301 
    302     var body: some View {
    303         if let talerUri {
    304             let contactDest = ContactsView(stack: stack.push(), talerURI: talerUri, subject: subject)
    305             let actions = Group {
    306                 NavLink($showContacts) { contactDest }
    307             }
    308             Button(action: shareAction) {
    309                 HStack {
    310                     Image(systemName: ICONNAME_PERSON3)        // 􀝊
    311                         .accessibility(hidden: true)
    312                     Text(title)
    313                 }
    314             }
    315             .background(actions)
    316             .tint(WalletColors().primaryAccent)
    317             .talerFont(.body)
    318             .scaleEffect(scale)
    319             .disabled(!isEnabled)
    320         }
    321     }
    322 }
    323 // MARK: -
    324 struct CopyShare: View {
    325     let stack: CallStack
    326     let textToCopy: String      // either talerURI, or report
    327     let image: UIImage?         // either QRcode of talerURI, or nil (report)
    328     let subject: String?
    329     let talerURI: String?
    330 
    331     @Environment(\.isEnabled) private var isEnabled: Bool
    332 
    333     init(_ stack: CallStack, _ string: String, image: UIImage? = nil,
    334          subject: String? = nil, talerURI: String? = nil
    335     ) {
    336         self.stack = stack
    337         self.textToCopy = string
    338         self.image = image
    339         self.subject = subject
    340 #if GNU_TALER || TALER_NIGHTLY
    341         self.talerURI = talerURI
    342 #else
    343         self.talerURI = nil
    344 #endif
    345     }
    346 
    347     var body: some View {
    348         let withContacts: Bool = talerURI != nil
    349         let copyB = CopyButton(textToCopy, vertical: false, image: image)
    350                 .buttonStyle(TalerButtonStyle(type: .bordered))
    351         let share = ShareButton(textToCopy, image: image)
    352                 .buttonStyle(TalerButtonStyle(type: .bordered))
    353         let contacts = ContactsButton(stack.push(), talerUri: talerURI, subject: subject)
    354             .buttonStyle(TalerButtonStyle(type: .bordered))
    355 
    356         let vLayout = VStack {
    357             copyB
    358             share
    359             if withContacts {
    360                 contacts
    361             }
    362         }
    363 
    364         if #available(iOS 16.0, *) {
    365             let hLayout = HStack(spacing: HSPACING) {
    366                 copyB
    367                 share
    368                 if withContacts {
    369                     contacts
    370                 }
    371             }
    372 
    373             if withContacts {
    374                 let mLayout = VStack {
    375                     HStack(spacing: HSPACING) {
    376                         copyB
    377                         share
    378                     }
    379                     contacts
    380                 }
    381 
    382                 ViewThatFits(in: .horizontal) {
    383                     hLayout
    384                     mLayout
    385                     vLayout
    386                 }
    387             } else {
    388                 ViewThatFits(in: .horizontal) {
    389                     hLayout
    390                     vLayout
    391                 }
    392             }
    393         } else {
    394             vLayout
    395         }
    396     }
    397 }
    398 // MARK: -
    399 struct CopyShare_Previews: PreviewProvider {
    400     static var previews: some View {
    401         CopyShare(CallStack("Preview"), "Hallö", image: nil)
    402     }
    403 }