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 }