LnurlAmountView.swift (9165B)
1 // 2 // LnurlAmountView.swift 3 // damus 4 // 5 // Created by Daniel D’Aquino on 2025-06-18 6 // 7 8 import SwiftUI 9 import Combine 10 11 class LnurlAmountModel: ObservableObject { 12 @Published var custom_amount: String = "0" 13 @Published var custom_amount_sats: Int? = 0 14 @Published var processing: Bool = false 15 @Published var error: String? = nil 16 @Published var invoice: String? = nil 17 @Published var zap_amounts: [ZapAmountItem] = [] 18 19 func set_defaults(settings: UserSettingsStore) { 20 let default_amount = settings.default_zap_amount 21 custom_amount = String(default_amount) 22 custom_amount_sats = default_amount 23 zap_amounts = get_zap_amount_items(default_amount) 24 } 25 } 26 27 /// Enables the user to enter a Bitcoin amount to be sent. Based on `CustomizeZapView`. 28 struct LnurlAmountView: View { 29 let damus_state: DamusState 30 let lnurlString: String 31 let onInvoiceFetched: (Invoice) -> Void 32 let onCancel: () -> Void 33 34 @StateObject var model: LnurlAmountModel = LnurlAmountModel() 35 @Environment(\.colorScheme) var colorScheme 36 @FocusState var isAmountFocused: Bool 37 38 init(damus_state: DamusState, lnurlString: String, onInvoiceFetched: @escaping (Invoice) -> Void, onCancel: @escaping () -> Void) { 39 self.damus_state = damus_state 40 self.lnurlString = lnurlString 41 self.onInvoiceFetched = onInvoiceFetched 42 self.onCancel = onCancel 43 } 44 45 func AmountButton(zapAmountItem: ZapAmountItem) -> some View { 46 let isSelected = model.custom_amount_sats == zapAmountItem.amount 47 48 return Button(action: { 49 model.custom_amount_sats = zapAmountItem.amount 50 model.custom_amount = String(zapAmountItem.amount) 51 }) { 52 let fmt = format_msats_abbrev(Int64(zapAmountItem.amount) * 1000) 53 Text(verbatim: "\(zapAmountItem.icon)\n\(fmt)") 54 .contentShape(Rectangle()) 55 .font(.headline) 56 .frame(width: 70, height: 70) 57 .foregroundColor(DamusColors.adaptableBlack) 58 .background(isSelected ? DamusColors.adaptableWhite : DamusColors.adaptableGrey) 59 .cornerRadius(15) 60 .overlay(RoundedRectangle(cornerRadius: 15) 61 .stroke(DamusColors.purple.opacity(isSelected ? 1.0 : 0.0), lineWidth: 2)) 62 } 63 } 64 65 func amount_parts(_ n: Int) -> [ZapAmountItem] { 66 var i: Int = -1 67 let start = n * 4 68 let end = start + 4 69 70 return model.zap_amounts.filter { _ in 71 i += 1 72 return i >= start && i < end 73 } 74 } 75 76 func AmountsPart(n: Int) -> some View { 77 HStack(alignment: .center, spacing: 15) { 78 ForEach(amount_parts(n)) { entry in 79 AmountButton(zapAmountItem: entry) 80 } 81 } 82 } 83 84 var AmountGrid: some View { 85 VStack { 86 AmountsPart(n: 0) 87 88 AmountsPart(n: 1) 89 } 90 .padding(10) 91 } 92 93 var CustomAmountTextField: some View { 94 VStack(alignment: .center, spacing: 0) { 95 TextField("", text: $model.custom_amount) 96 .focused($isAmountFocused) 97 .task { 98 self.isAmountFocused = true 99 } 100 .font(.system(size: 72, weight: .heavy)) 101 .minimumScaleFactor(0.01) 102 .keyboardType(.numberPad) 103 .multilineTextAlignment(.center) 104 .onChange(of: model.custom_amount) { newValue in 105 if let parsed = handle_string_amount(new_value: newValue) { 106 model.custom_amount = parsed.formatted() 107 model.custom_amount_sats = parsed 108 } else { 109 model.custom_amount = "0" 110 model.custom_amount_sats = nil 111 } 112 } 113 let noun = pluralizedString(key: "sats", count: model.custom_amount_sats ?? 0) 114 Text(noun) 115 .font(.system(size: 18, weight: .heavy)) 116 } 117 } 118 119 func fetchInvoice() { 120 guard let amount = model.custom_amount_sats, amount > 0 else { 121 model.error = NSLocalizedString("Please enter a valid amount", comment: "Error message when no valid amount is entered for LNURL payment") 122 return 123 } 124 125 model.processing = true 126 model.error = nil 127 128 Task { @MainActor in 129 // For LNURL payments without zaps, we use nil for zapreq and comment 130 // We just need the invoice for payment 131 let msats = Int64(amount) * 1000 132 133 // First get the payment request from the LNURL 134 guard let payreq = await fetch_static_payreq(lnurlString) else { 135 model.processing = false 136 model.error = NSLocalizedString("Error fetching LNURL payment information", comment: "Error message when LNURL fetch fails") 137 return 138 } 139 140 // Then fetch the invoice with the amount 141 guard let invoiceStr = await fetch_zap_invoice(payreq, zapreq: nil, msats: msats, zap_type: .non_zap, comment: nil) else { 142 model.processing = false 143 model.error = NSLocalizedString("Error fetching lightning invoice", comment: "Error message when there was an error fetching a lightning invoice") 144 return 145 } 146 147 // Decode the invoice to validate it 148 guard let invoice = decode_bolt11(invoiceStr) else { 149 model.processing = false 150 model.error = NSLocalizedString("Invalid lightning invoice received", comment: "Error message when the lightning invoice received from LNURL is invalid") 151 return 152 } 153 154 // All good, pass the invoice back to the parent view 155 model.processing = false 156 onInvoiceFetched(invoice) 157 } 158 } 159 160 var PayButton: some View { 161 VStack { 162 if model.processing { 163 Text("Processing...", comment: "Text to indicate that the app is in the process of fetching an invoice.") 164 .padding() 165 ProgressView() 166 } else { 167 Button(action: { 168 fetchInvoice() 169 }) { 170 HStack { 171 Text("Continue", comment: "Button to proceed with LNURL payment process.") 172 .font(.system(size: 20, weight: .bold)) 173 } 174 .frame(minWidth: 300, maxWidth: .infinity, alignment: .center) 175 } 176 .buttonStyle(GradientButtonStyle()) 177 .disabled(model.custom_amount_sats == 0 || model.custom_amount == "0") 178 .opacity(model.custom_amount_sats == 0 || model.custom_amount == "0" ? 0.5 : 1.0) 179 .padding(10) 180 } 181 182 if let error = model.error { 183 Text(error) 184 .foregroundColor(.red) 185 .padding() 186 } 187 } 188 } 189 190 var CancelButton: some View { 191 Button(action: onCancel) { 192 HStack { 193 Text("Cancel", comment: "Button to cancel the LNURL payment process.") 194 .font(.headline) 195 .padding() 196 } 197 .frame(minWidth: 300, maxWidth: .infinity, alignment: .center) 198 } 199 .buttonStyle(NeutralButtonStyle()) 200 .padding() 201 } 202 203 var body: some View { 204 VStack(alignment: .center, spacing: 20) { 205 ScrollView { 206 VStack { 207 Text("Enter Amount", comment: "Header text for LNURL payment amount entry screen") 208 .font(.title) 209 .fontWeight(.bold) 210 .padding() 211 212 Text("How much would you like to send?", comment: "Instruction text for LNURL payment amount") 213 .font(.headline) 214 .multilineTextAlignment(.center) 215 .padding(.bottom) 216 217 CustomAmountTextField 218 219 AmountGrid 220 221 PayButton 222 223 CancelButton 224 } 225 } 226 } 227 .onAppear { 228 model.set_defaults(settings: damus_state.settings) 229 } 230 .onTapGesture { 231 hideKeyboard() 232 } 233 } 234 } 235 236 struct LnurlAmountView_Previews: PreviewProvider { 237 static var previews: some View { 238 LnurlAmountView( 239 damus_state: test_damus_state, 240 lnurlString: "lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns", 241 onInvoiceFetched: { _ in }, 242 onCancel: {} 243 ) 244 .frame(width: 400, height: 600) 245 } 246 }