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