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