ActionsSheet.swift (16728B)
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 SwiftUI 9 import os.log 10 import taler_swift 11 12 struct ScannedURLs: View { 13 let stack: CallStack 14 // @Environment(\.openURL) private var openURL NEVER EVER use this to pass a URL to myself - iOS might open another app 15 @EnvironmentObject private var controller: Controller 16 17 var body: some View { 18 VStack { 19 Text("Scanned Taler codes:") 20 .talerFont(.body) 21 .multilineTextAlignment(.leading) 22 .fixedSize(horizontal: false, vertical: true) 23 .padding(.bottom, -2) 24 ForEach(controller.scannedURLs) { scannedURL in 25 let action = { 26 dismissTop(stack.push()) 27 DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { 28 // need to wait before opening the next sheet, otherwise SwiftUI doesn't update! 29 // openURL(scannedURL.url) don't do this - iOS might open another wallet app 30 controller.talerURI = scannedURL.url // instead pass it via .onChange(of: controller.talerURI) directly to MainView 31 } 32 } 33 Button(action: action) { 34 HStack { 35 Text(scannedURL.command.localizedCommand) 36 let primaryAccent = WalletColors().primaryAccent 37 ButtonIconBadge(type: scannedURL.command.transactionType, 38 phase: 0, 39 foreColor: primaryAccent, 40 done: false) 41 .talerFont(.title2) 42 43 Spacer() 44 if let amount = scannedURL.amount { 45 AmountV(scannedURL.scope, amount, isNegative: nil) 46 } else if let baseURL = scannedURL.baseURL { 47 Text(baseURL.trimURL) 48 } 49 } 50 .padding(.horizontal, 8) 51 } 52 .buttonStyle(TalerButtonStyle(type: .bordered, narrow: false, aligned: .center)) 53 .swipeActions(edge: .trailing) { 54 Button { 55 // symLog?.log("deleteAction") 56 Task { // runs on MainActor 57 controller.removeURL(scannedURL.url) 58 } 59 } label: { 60 Label("Delete", systemImage: "trash") 61 } 62 .tint(WalletColors().negative) 63 } 64 65 } 66 }.padding(.bottom, 20) 67 } 68 } 69 // MARK: - 70 /// This view shows the action sheet 71 /// optional: [Spend KUDOS] 72 /// [ Send ] [ Request] 73 /// [Deposit] [ Withdraw] 74 /// [ Scan QR ] 75 struct ActionsSheet: View { 76 let stack: CallStack 77 78 @EnvironmentObject private var controller: Controller 79 @AppStorage("demoHints") var demoHints: Bool = true 80 @AppStorage("pasteAutomatically") var pasteAutomatically: Bool = false 81 82 private var hasKudos: Bool { 83 for balance in controller.balances { 84 if balance.scopeInfo.currency == DEMOCURRENCY { 85 if !balance.available.isZero { 86 return true 87 } 88 } 89 } 90 return false 91 } 92 private var mayDeposit: Bool { // returns true if at least 1 balance didn't disable deposits 93 for balance in controller.balances { 94 if !(balance.disableDirectDeposits == true) { // false or nil 95 return true 96 } 97 } 98 return false 99 } 100 private var mayP2P: Bool { // returns true if at least 1 balance didn't disable P2P 101 for balance in controller.balances { 102 if !(balance.disablePeerPayments == true) { // false or nil 103 return true 104 } 105 } 106 return false 107 } 108 109 var body: some View { 110 VStack { 111 Spacer(minLength: 4) // leave space under presentationDragIndicator 112 .frame(maxHeight: 8) 113 114 if let balance = controller.balances.first, 115 let shoppingUrls = balance.shoppingUrls, 116 !shoppingUrls.isEmpty 117 { 118 let currency = balance.scopeInfo.currency 119 ShoppingView(stack: stack.push(), 120 currency: currency, 121 buttonTitle: String(localized: "LinkTitle_SHOPS", defaultValue: "Where to pay with \(currency)"), 122 buttonA11yHint: String(localized: "Will go to the map of shops.", comment: "a11y"), 123 buttonLink: shoppingUrls.first!) 124 } else if hasKudos && demoHints { 125 ShoppingView(stack: stack.push(), 126 currency: nil, 127 buttonTitle: String(localized: "LinkTitle_DEMOSHOP", defaultValue: "Spend demo money"), 128 buttonA11yHint: String(localized: "Will go to the demo shop website.", comment: "a11y"), 129 buttonLink: DEMOSHOP) 130 } 131 if !controller.scannedURLs.isEmpty { 132 ScannedURLs(stack: stack.push()) 133 } 134 135 let noBalances = controller.balances.isEmpty 136 let noP2P = !self.mayP2P 137 let sendDisabled = noP2P 138 let recvDisabled = noBalances || noP2P 139 let depoDisabled = !mayDeposit 140 TimelineView(.periodic(from: .now, by: 60/80)) { context in 141 PhaseView(stack: stack.push(), date: context.date, 142 sendDisabled: sendDisabled, recvDisabled: recvDisabled, 143 depoDisabled: depoDisabled, wthdDisabled: noBalances) 144 } 145 HStack { 146 if #available(iOS 26.0, *) { 147 if !pasteAutomatically { 148 #if TALER_NIGHTLY 149 PasteButton(supportedContentTypes: [.url]) { providers in 150 for provider in providers { 151 if provider.canLoadObject(ofClass: String.self) { 152 _ = provider.loadObject(ofClass: String.self) { string, error in 153 guard let string else { return } 154 if let pastedURL = string.toURL { 155 let scheme = pastedURL.scheme 156 if scheme?.lowercased() == "taler" { 157 let pasteType = PasteType(pastedURL: pastedURL) 158 let userinfo = [NOTIFICATIONPASTE: pasteType] 159 NotificationCenter.default.post(name: .PasteAction, object: nil, userInfo: userinfo) 160 } 161 } 162 } 163 } 164 } 165 } 166 .labelStyle(.iconOnly) 167 .tint(WalletColors().primaryAccent) 168 .buttonBorderShape(.circle) // capsule 169 .accessibility(sortPriority: 1) 170 #else 171 let isEnabled = UIPasteboard.general.hasURLs 172 TalerPasteButton(accessibilityLabelStr: "Paste") { 173 Task { 174 TalerWallet1App().inspectPasteboard(playSound: true) 175 } 176 }.buttonStyle(TalerButtonStyle(type: .bordered, 177 dimmed: false, 178 narrow: true, 179 disabled: !isEnabled, 180 aligned: .center)) 181 .accessibility(sortPriority: 1) 182 #endif 183 } 184 Spacer(minLength: 8) 185 } 186 /// [ Scan QR ] 187 QRButton(hideTitle: false) { 188 Task { 189 NotificationCenter.default.post(name: .QrScanAction, object: nil) // will 190 } 191 } 192 .lineLimit(5) 193 .buttonStyle(TalerButtonStyle(type: .bordered, narrow: true, aligned: .center)) 194 .accessibility(sortPriority: 3) // read this first 195 } 196 } 197 .padding(.top) 198 .padding(.horizontal) 199 .padding(.bottom, -8) 200 .accessibilityElement(children: .contain) 201 } 202 } 203 // MARK: - 204 struct ShoppingView: View { 205 let stack: CallStack 206 let currency: String? 207 let buttonTitle: String 208 let buttonA11yHint: String 209 let buttonLink: String 210 211 @AppStorage("minimalistic") var minimalistic: Bool = false 212 213 var body: some View { 214 Group { 215 if let currency { 216 Text(minimalistic ? "Spend your \(currency) in these shops:" 217 : "You can spend your \(currency) in these shops, or send them to another wallet.") 218 } else { 219 Text(minimalistic ? "Spend your \(DEMOCURRENCY) in the Demo shop" 220 : "You can spend your \(DEMOCURRENCY) in the Demo shop, or send them to another wallet.") 221 } 222 } 223 .talerFont(.body) 224 .multilineTextAlignment(.leading) 225 .fixedSize(horizontal: false, vertical: true) // must set this otherwise fixedInnerHeight won't work 226 227 let image = Image(systemName: currency == nil ? LINK : MAPPIN) // or 228 Button("\(image) \(buttonTitle)") { 229 if let url = URL(string: buttonLink) { 230 UIApplication.shared.open(url, options: [:]) 231 } 232 dismissTop(stack.push()) 233 } 234 .buttonStyle(TalerButtonStyle(type: .bordered, narrow: false, aligned: .center)) 235 .accessibilityLabel(buttonTitle) 236 .accessibilityAddTraits(.isLink) 237 .accessibilityHint(buttonA11yHint) 238 .padding(.bottom, 20) 239 } 240 } 241 // MARK: - 242 @available(iOS 16.4, *) 243 struct DualHeightSheet: View { 244 let stack: CallStack 245 let selectedBalance: Balance? 246 let dismissScanner: () -> Void 247 248 let logger = Logger(subsystem: "net.taler.gnu", category: "DualSheet") 249 @Environment(\.colorScheme) private var colorScheme 250 @Environment(\.colorSchemeContrast) private var colorSchemeContrast 251 252 @AppStorage("minimalistic") var minimalistic: Bool = false 253 // @State private var selectedDetent: PresentationDetent = scanDetent // Cannot use instance member 'scanDetent' within property initializer 254 @State private var selectedDetent: PresentationDetent = .fraction(0.1) // workaround - update in .task 255 @State private var supportedDetents: Set<PresentationDetent> = [.fraction(0.1)] // " 256 // @State private var qrButtonTapped2: Bool = false 257 @State private var scannedCode: Bool = false 258 @State private var innerHeight: CGFloat = .zero 259 @State private var sheetHeight: CGFloat = .zero 260 261 let halfDetent: PresentationDetent = .fraction(HALFDETENT) 262 let fullDetent: PresentationDetent = .fraction(FULLDETENT) 263 264 func updateDetentsWithDelay() { 265 Task { 266 //(1 second = 1_000_000_000 nanoseconds) 267 try? await Task.sleep(nanoseconds: 80_000_000) 268 let detent = minimalistic ? halfDetent : fullDetent 269 guard selectedDetent == detent else { return } 270 supportedDetents = [detent] 271 logger.trace("updateDetentsWithDelay❗️supportedDetents = [scanDetent]") // 0.999 % 272 } 273 } 274 275 func actionsSheet() -> some View { 276 let actionsSheet = ActionsSheet(stack: stack.push()) 277 .presentationDragIndicator(.visible) 278 if #available(iOS 28.0, *) { // TODO: adapt ActionsSheet() to glass-buttons 279 return actionsSheet 280 .presentationSizing(.fitted) // iOS 18+ 281 } else { 282 let background = colorScheme == .dark ? WalletColors().gray6 283 : WalletColors().gray2 284 return actionsSheet 285 .presentationBackground { 286 background 287 /// overflow the bottom of the screen by a sufficient amount to fill the gap that is seen when the size changes 288 .padding(.bottom, -1000) 289 } 290 } 291 } 292 293 var body: some View { 294 let scrollView = ScrollView { 295 actionsSheet() 296 .innerHeight($innerHeight) 297 // .onChange(of: qrButtonTapped2) { tapped2 in 298 // if tapped2 { 299 //// logger.trace("❗️the user tapped") 300 // NotificationCenter.default.post(name: .QrScanAction, object: nil) // will trigger 301 // withAnimation(Animation.easeIn(duration: 0.6)) { 302 // // animate this sheet to full height 303 // let detent = minimalistic ? halfDetent : fullDetent 304 // logger.trace("qrButtonTapped2❗️selected_Detent = \(FULLDETENT)") 305 // selectedDetent = detent 306 // } 307 // } 308 // } 309 // .onChange(of: qrButtonTapped) { tapped in 310 // if !tapped { 311 // logger.trace("❗️dismissed, cleanup") 312 // sheetHeight = innerHeight 313 // qrButtonTapped2 = false 314 // }//tapped 315 // }//qr 316 .onChange(of: innerHeight) { newHeight in 317 logger.trace("onChange❗️innerHeight => set sheetHeight: \(sheetHeight) -> \(newHeight)❗️") 318 sheetHeight = newHeight 319 selectedDetent = .height(newHeight) // will update supportedDetents in .onChange(:) 320 logger.trace("did set selectedDetent and sheetHeight to new innerHeight") 321 } 322 .presentationDetents(supportedDetents, selection: $selectedDetent) 323 .onChange(of: selectedDetent) { newValue in 324 let detent = minimalistic ? halfDetent : fullDetent 325 if newValue == detent { // user swiped the sheet up to activate QR scanner 326 logger.trace("onChange❗️selectedDetent == (detent) ==> updateDetentsWithDelay()") 327 updateDetentsWithDelay() 328 NotificationCenter.default.post(name: .QrScanAction, object: nil) // will trigger 329 } else { // SwiftUI "innerHeight" determined how big the half sheet should be 330 logger.trace("onChange❗️selectedDetent = .height(\(sheetHeight)) supportedDetents = [detent, .height(sheetHeight)]") 331 supportedDetents = [detent, newValue] 332 // logger.trace("did set supportedDetents to [\(detent), .height(\(sheetHeight))]") 333 let _ = print("did set supportedDetents to [\(detent), newValue: \(newValue))]") 334 } 335 } 336 .task { 337 logger.trace("task❗️selectedDetent = .height(\(sheetHeight))") 338 selectedDetent = .height(sheetHeight) // will update supportedDetents in .onChange(:) 339 } 340 // .animation(.spring(), value: selectedDetent) 341 } 342 .ignoresSafeArea() 343 .frame(maxHeight: innerHeight) 344 345 if #available(iOS 18.0, *) { 346 scrollView 347 // .sheet(isPresented: $qrButtonTapped, 348 // onDismiss: dismissScanner 349 // ) { 350 // let qrSheet = AnyView(QRSheet(stack: stack.push(".sheet"), 351 // selectedBalance: selectedBalance, 352 // scannedSomething: $scannedCode)) 353 // let detent: PresentationDetent = .fraction(scannedCode ? FULLDETENT 354 // : minimalistic ? HALFDETENT : FULLDETENT) 355 // Sheet(stack: stack.push(), sheetView: qrSheet) 356 // .presentationDetents([detent]) 357 // .transition(.opacity) 358 // } 359 .defaultScrollAnchor(.top) 360 } else { 361 scrollView 362 } 363 } 364 }