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