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