ActionsSheet.swift (14966B)
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 let isEnabled = UIPasteboard.general.hasURLs 148 PasteButton(accessibilityLabelStr: "Paste") { 149 Task { 150 TalerWallet1App().inspectPasteboard(playSound: true) 151 } 152 }.buttonStyle(TalerButtonStyle(type: .bordered, 153 dimmed: false, 154 narrow: true, 155 disabled: !isEnabled, 156 aligned: .center)) 157 .accessibility(sortPriority: 1) 158 } 159 Spacer(minLength: 8) 160 } 161 /// [ Scan QR ] 162 QRButton(hideTitle: false) { 163 Task { 164 NotificationCenter.default.post(name: .QrScanAction, object: nil) // will 165 } 166 } 167 .lineLimit(5) 168 .buttonStyle(TalerButtonStyle(type: .bordered, narrow: true, aligned: .center)) 169 .accessibility(sortPriority: 3) // read this first 170 } 171 } 172 .padding(.top) 173 .padding(.horizontal) 174 .padding(.bottom, -8) 175 .accessibilityElement(children: .contain) 176 } 177 } 178 // MARK: - 179 struct ShoppingView: View { 180 let stack: CallStack 181 let currency: String? 182 let buttonTitle: String 183 let buttonA11yHint: String 184 let buttonLink: String 185 186 @AppStorage("minimalistic") var minimalistic: Bool = false 187 188 var body: some View { 189 Group { 190 if let currency { 191 Text(minimalistic ? "Spend your \(currency) in these shops:" 192 : "You can spend your \(currency) in these shops, or send them to another wallet.") 193 } else { 194 Text(minimalistic ? "Spend your \(DEMOCURRENCY) in the Demo shop" 195 : "You can spend your \(DEMOCURRENCY) in the Demo shop, or send them to another wallet.") 196 } 197 } 198 .talerFont(.body) 199 .multilineTextAlignment(.leading) 200 .fixedSize(horizontal: false, vertical: true) // must set this otherwise fixedInnerHeight won't work 201 202 let image = Image(systemName: currency == nil ? LINK : MAPPIN) // or 203 Button("\(image) \(buttonTitle)") { 204 UIApplication.shared.open(URL(string: buttonLink)!, options: [:]) 205 dismissTop(stack.push()) 206 } 207 .buttonStyle(TalerButtonStyle(type: .bordered, narrow: false, aligned: .center)) 208 .accessibilityLabel(buttonTitle) 209 .accessibilityAddTraits(.isLink) 210 .accessibilityHint(buttonA11yHint) 211 .padding(.bottom, 20) 212 } 213 } 214 // MARK: - 215 @available(iOS 16.4, *) 216 struct DualHeightSheet: View { 217 let stack: CallStack 218 let selectedBalance: Balance? 219 let dismissScanner: () -> Void 220 221 let logger = Logger(subsystem: "net.taler.gnu", category: "DualSheet") 222 @Environment(\.colorScheme) private var colorScheme 223 @Environment(\.colorSchemeContrast) private var colorSchemeContrast 224 225 @AppStorage("minimalistic") var minimalistic: Bool = false 226 // @State private var selectedDetent: PresentationDetent = scanDetent // Cannot use instance member 'scanDetent' within property initializer 227 @State private var selectedDetent: PresentationDetent = .fraction(0.1) // workaround - update in .task 228 @State private var supportedDetents: Set<PresentationDetent> = [.fraction(0.1)] // " 229 // @State private var qrButtonTapped2: Bool = false 230 @State private var scannedCode: Bool = false 231 @State private var innerHeight: CGFloat = .zero 232 @State private var sheetHeight: CGFloat = .zero 233 234 let halfDetent: PresentationDetent = .fraction(HALFDETENT) 235 let fullDetent: PresentationDetent = .fraction(FULLDETENT) 236 237 func updateDetentsWithDelay() { 238 Task { 239 //(1 second = 1_000_000_000 nanoseconds) 240 try? await Task.sleep(nanoseconds: 80_000_000) 241 let detent = minimalistic ? halfDetent : fullDetent 242 guard selectedDetent == detent else { return } 243 supportedDetents = [detent] 244 logger.trace("updateDetentsWithDelay❗️supportedDetents = [scanDetent]") // 0.999 % 245 } 246 } 247 248 func actionsSheet() -> some View { 249 let actionsSheet = ActionsSheet(stack: stack.push()) 250 .presentationDragIndicator(.visible) 251 if #available(iOS 28.0, *) { // TODO: adapt ActionsSheet() to glass-buttons 252 return actionsSheet 253 .presentationSizing(.fitted) // iOS 18+ 254 } else { 255 let background = colorScheme == .dark ? WalletColors().gray6 256 : WalletColors().gray2 257 return actionsSheet 258 .presentationBackground { 259 background 260 /// overflow the bottom of the screen by a sufficient amount to fill the gap that is seen when the size changes 261 .padding(.bottom, -1000) 262 } 263 } 264 } 265 266 var body: some View { 267 let scrollView = ScrollView { 268 actionsSheet() 269 .innerHeight($innerHeight) 270 // .onChange(of: qrButtonTapped2) { tapped2 in 271 // if tapped2 { 272 //// logger.trace("❗️the user tapped") 273 // NotificationCenter.default.post(name: .QrScanAction, object: nil) // will trigger 274 // withAnimation(Animation.easeIn(duration: 0.6)) { 275 // // animate this sheet to full height 276 // let detent = minimalistic ? halfDetent : fullDetent 277 // logger.trace("qrButtonTapped2❗️selected_Detent = \(FULLDETENT)") 278 // selectedDetent = detent 279 // } 280 // } 281 // } 282 // .onChange(of: qrButtonTapped) { tapped in 283 // if !tapped { 284 // logger.trace("❗️dismissed, cleanup") 285 // sheetHeight = innerHeight 286 // qrButtonTapped2 = false 287 // }//tapped 288 // }//qr 289 .onChange(of: innerHeight) { newHeight in 290 logger.trace("onChange❗️innerHeight => set sheetHeight: \(sheetHeight) -> \(newHeight)❗️") 291 sheetHeight = newHeight 292 selectedDetent = .height(newHeight) // will update supportedDetents in .onChange(:) 293 logger.trace("did set selectedDetent and sheetHeight to new innerHeight") 294 } 295 .presentationDetents(supportedDetents, selection: $selectedDetent) 296 .onChange(of: selectedDetent) { newValue in 297 let detent = minimalistic ? halfDetent : fullDetent 298 if newValue == detent { // user swiped the sheet up to activate QR scanner 299 logger.trace("onChange❗️selectedDetent == (detent) ==> updateDetentsWithDelay()") 300 updateDetentsWithDelay() 301 NotificationCenter.default.post(name: .QrScanAction, object: nil) // will trigger 302 } else { // SwiftUI "innerHeight" determined how big the half sheet should be 303 logger.trace("onChange❗️selectedDetent = .height(\(sheetHeight)) supportedDetents = [detent, .height(sheetHeight)]") 304 supportedDetents = [detent, newValue] 305 // logger.trace("did set supportedDetents to [\(detent), .height(\(sheetHeight))]") 306 let _ = print("did set supportedDetents to [\(detent), newValue: \(newValue))]") 307 } 308 } 309 .task { 310 logger.trace("task❗️selectedDetent = .height(\(sheetHeight))") 311 selectedDetent = .height(sheetHeight) // will update supportedDetents in .onChange(:) 312 } 313 // .animation(.spring(), value: selectedDetent) 314 } 315 .ignoresSafeArea() 316 .frame(maxHeight: innerHeight) 317 318 if #available(iOS 18.0, *) { 319 scrollView 320 // .sheet(isPresented: $qrButtonTapped, 321 // onDismiss: dismissScanner 322 // ) { 323 // let qrSheet = AnyView(QRSheet(stack: stack.push(".sheet"), 324 // selectedBalance: selectedBalance, 325 // scannedSomething: $scannedCode)) 326 // let detent: PresentationDetent = .fraction(scannedCode ? FULLDETENT 327 // : minimalistic ? HALFDETENT : FULLDETENT) 328 // Sheet(stack: stack.push(), sheetView: qrSheet) 329 // .presentationDetents([detent]) 330 // .transition(.opacity) 331 // } 332 .defaultScrollAnchor(.top) 333 } else { 334 scrollView 335 } 336 } 337 }