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