damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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 }