damus

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

CustomizeZapView.swift (13067B)


      1 //
      2 //  CustomizeZapView.swift
      3 //  damus
      4 //
      5 //  Created by William Casarin on 2023-02-25.
      6 //
      7 
      8 import SwiftUI
      9 import Combine
     10 
     11 struct ZapAmountItem: Identifiable, Hashable {
     12     let amount: Int
     13     let icon: String
     14     
     15     var id: String {
     16         return icon
     17     }
     18 }
     19 
     20 func get_default_zap_amount_item(_ def: Int) -> ZapAmountItem {
     21     return ZapAmountItem(amount: def, icon: "🤙")
     22 }
     23 
     24 func get_zap_amount_items(_ default_zap_amt: Int) -> [ZapAmountItem] {
     25     let def_item = get_default_zap_amount_item(default_zap_amt)
     26     var entries = [
     27         ZapAmountItem(amount: 69, icon: "😘"),
     28         ZapAmountItem(amount: 420, icon: "🌿"),
     29         ZapAmountItem(amount: 5000, icon: "💜"),
     30         ZapAmountItem(amount: 10_000, icon: "😍"),
     31         ZapAmountItem(amount: 20_000, icon: "🤩"),
     32         ZapAmountItem(amount: 50_000, icon: "🔥"),
     33         ZapAmountItem(amount: 100_000, icon: "🚀"),
     34         ZapAmountItem(amount: 1_000_000, icon: "🤯"),
     35     ]
     36     entries.append(def_item)
     37     
     38     entries.sort { $0.amount < $1.amount }
     39     return entries
     40 }
     41 
     42 enum ZapFields{
     43     case amount
     44     case comment
     45 }
     46 
     47 struct CustomizeZapView: View {
     48     let state: DamusState
     49     let target: ZapTarget
     50     let lnurl: String
     51     
     52     let zap_amounts: [ZapAmountItem]
     53     
     54     @FocusState var focusedTextField : ZapFields?
     55     
     56     @StateObject var model: CustomizeZapModel = CustomizeZapModel()
     57     @Environment(\.dismiss) var dismiss
     58     @Environment(\.colorScheme) var colorScheme
     59     
     60     func fillColor() -> Color {
     61         colorScheme == .light ? DamusColors.white : DamusColors.black
     62     }
     63     
     64     func fontColor() -> Color {
     65         colorScheme == .light ? DamusColors.black : DamusColors.white
     66     }
     67     
     68     init(state: DamusState, target: ZapTarget, lnurl: String) {
     69         self.target = target
     70         self.zap_amounts = get_zap_amount_items(state.settings.default_zap_amount)
     71         self.lnurl = lnurl
     72         self.state = state
     73     }
     74     
     75     func amount_parts(_ n: Int) -> [ZapAmountItem] {
     76         var i: Int = -1
     77         let start = n * 4
     78         let end = start + 4
     79         
     80         return zap_amounts.filter { _ in
     81             i += 1
     82             return i >= start && i < end
     83         }
     84     }
     85     
     86     func AmountsPart(n: Int) -> some View {
     87         HStack(alignment: .center, spacing: 15) {
     88             ForEach(amount_parts(n)) { entry in
     89                 ZapAmountButton(zapAmountItem: entry, action: {
     90                     model.custom_amount_sats = entry.amount
     91                     model.custom_amount = String(entry.amount)
     92                 })
     93             }
     94         }
     95     }
     96     
     97     var AmountPicker: some View {
     98         VStack {
     99             AmountsPart(n: 0)
    100             
    101             AmountsPart(n: 1)
    102         }
    103         .padding(10)
    104     }
    105     
    106     func ZapAmountButton(zapAmountItem: ZapAmountItem, action: @escaping () -> ()) -> some View {
    107         Button(action: action) {
    108             let fmt = format_msats_abbrev(Int64(zapAmountItem.amount) * 1000)
    109             Text(verbatim: "\(zapAmountItem.icon)\n\(fmt)")
    110                 .contentShape(Rectangle())
    111                 .font(.headline)
    112                 .frame(width: 70, height: 70)
    113                 .foregroundColor(fontColor())
    114                 .background(model.custom_amount_sats == zapAmountItem.amount ? fillColor() : DamusColors.adaptableGrey)
    115                 .cornerRadius(15)
    116                 .overlay(RoundedRectangle(cornerRadius: 15)
    117                     .stroke(DamusColors.purple.opacity(model.custom_amount_sats == zapAmountItem.amount ? 1.0 : 0.0), lineWidth: 2))
    118         }
    119     }
    120     
    121     var CustomZapTextField: some View {
    122         VStack(alignment: .center, spacing: 0) {
    123             TextField("", text: $model.custom_amount)
    124                 .focused($focusedTextField, equals: ZapFields.amount)
    125                 .task {
    126                     self.focusedTextField = .amount
    127                 }
    128                 .font(.system(size: 72, weight: .heavy))
    129                 .minimumScaleFactor(0.01)
    130                 .keyboardType(.numberPad)
    131                 .multilineTextAlignment(.center)
    132                 .onChange(of: model.custom_amount) { newValue in
    133                     if let parsed = handle_string_amount(new_value: newValue) {
    134                         model.custom_amount = parsed.formatted()
    135                         model.custom_amount_sats = parsed
    136                     } else {
    137                         model.custom_amount = "0"
    138                         model.custom_amount_sats = nil
    139                     }
    140                 }
    141             let noun = pluralizedString(key: "sats", count: model.custom_amount_sats ?? 0)
    142             Text(noun)
    143                 .font(.system(size: 18, weight: .heavy))
    144         }
    145     }
    146     
    147     var ZapReply: some View {
    148         HStack {
    149             TextField(NSLocalizedString("Send a message with your zap...", comment: "Placeholder text for a comment to send as part of a zap to the user."), text: $model.comment, axis: .vertical)
    150                 .focused($focusedTextField, equals: ZapFields.comment)
    151                 .task {
    152                             self.focusedTextField = .comment
    153                 }
    154                 .autocorrectionDisabled(true)
    155                 .textInputAutocapitalization(.never)
    156                 .lineLimit(5)
    157         }
    158         .frame(minHeight: 30)
    159         .padding(10)
    160         .background(.secondary.opacity(0.2))
    161         .cornerRadius(10)
    162         .padding(.horizontal, 10)
    163     }
    164     
    165     var ZapButton: some View {
    166         VStack {
    167             if model.zapping {
    168                 Text("Zapping...", comment: "Text to indicate that the app is in the process of sending a zap.")
    169             } else {
    170                 Button(action: {
    171                     let amount = model.custom_amount_sats
    172                     send_zap(damus_state: state, target: target, lnurl: lnurl, is_custom: true, comment: model.comment, amount_sats: amount, zap_type: model.zap_type)
    173                     model.zapping = true
    174                 }) {
    175                     HStack {
    176                         Text("Zap User", comment: "Button to send a zap.")
    177                             .font(.system(size: 20, weight: .bold))
    178                     }
    179                     .frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
    180                 }
    181                 .buttonStyle(GradientButtonStyle())
    182                 .disabled(model.custom_amount_sats == 0 || model.custom_amount == "0")
    183                 .opacity(model.custom_amount_sats == 0 || model.custom_amount == "0" ? 0.5 : 1.0)
    184                 .padding(10)
    185             }
    186             
    187             if let error = model.error {
    188                 Text(error)
    189                     .foregroundColor(.red)
    190             }
    191         }
    192     }
    193     
    194     func receive_zap(zap_ev: ZappingEvent) {
    195         guard zap_ev.is_custom, zap_ev.target.id == target.id else {
    196             return
    197         }
    198         
    199         model.zapping = false
    200         
    201         switch zap_ev.type {
    202         case .failed(let err):
    203             model.error = err.humanReadableMessage()
    204             break
    205         case .got_zap_invoice(let inv):
    206             if state.settings.show_wallet_selector {
    207                 model.invoice = inv
    208                 present_sheet(.select_wallet(invoice: inv))
    209             } else {
    210                 end_editing()
    211                 let wallet = state.settings.default_wallet.model
    212                 do {
    213                     try open_with_wallet(wallet: wallet, invoice: inv)
    214                     dismiss()
    215                 }
    216                 catch {
    217                     present_sheet(.select_wallet(invoice: inv))
    218                 }
    219             }
    220         case .sent_from_nwc:
    221             dismiss()
    222         }
    223     }
    224     
    225     var body: some View {
    226         VStack(alignment: .center, spacing: 20) {
    227             ScrollView {
    228                 HStack(alignment: .center) {
    229                     UserView(damus_state: state, pubkey: target.pubkey)
    230                     
    231                     ZapTypeButton()
    232                 }
    233                 .padding([.horizontal, .top])
    234 
    235                 CustomZapTextField
    236                 
    237                 AmountPicker
    238                 
    239                 ZapReply
    240                 
    241                 ZapButton
    242                 
    243                 Spacer()
    244             }
    245         }
    246         .sheet(isPresented: $model.show_zap_types) {
    247             if #available(iOS 16.0, *) {
    248                 ZapPicker
    249                     .presentationDetents([.medium])
    250                     .presentationDragIndicator(.visible)
    251             } else {
    252                 ZapPicker
    253             }
    254         }
    255         .onAppear {
    256             model.set_defaults(settings: state.settings)
    257         }
    258         .onReceive(handle_notify(.zapping)) { zap_ev in
    259             receive_zap(zap_ev: zap_ev)
    260         }
    261         .background(fillColor().edgesIgnoringSafeArea(.all))
    262         .onTapGesture {
    263             hideKeyboard()
    264         }
    265     }
    266     
    267     func ZapTypeButton() -> some View {
    268         Button(action: {
    269             model.show_zap_types = true
    270         }) {
    271             switch model.zap_type {
    272             case .pub:
    273                 Image("globe")
    274                 Text("Public", comment: "Button text to indicate that the zap type is a public zap.")
    275             case .anon:
    276                 Image("question")
    277                 Text("Anonymous", comment: "Button text to indicate that the zap type is a anonymous zap.")
    278             case .priv:
    279                 Image("lock")
    280                 Text("Private", comment: "Button text to indicate that the zap type is a private zap.")
    281             case .non_zap:
    282                 Image("zap")
    283                 Text("None", comment: "Button text to indicate that the zap type is a private zap.")
    284             }
    285         }
    286         .font(.headline)
    287         .foregroundColor(fontColor())
    288         .padding(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
    289         .background(DamusColors.adaptableGrey)
    290         .cornerRadius(15)
    291     }
    292 
    293     var ZapPicker: some View {
    294         ZapTypePicker(zap_type: $model.zap_type, settings: state.settings, profiles: state.profiles, pubkey: target.pubkey)
    295     }
    296 }
    297 
    298 struct ZapSheetViewIfPossible: View {
    299     let damus_state: DamusState
    300     let target: ZapTarget
    301     let lnurl: String?
    302     var zap_sheet: ZapSheet? {
    303         guard let lnurl else { return nil }
    304         return ZapSheet(target: target, lnurl: lnurl)
    305     }
    306 
    307     @Environment(\.dismiss) var dismiss
    308     @Environment(\.colorScheme) var colorScheme
    309 
    310     var body: some View {
    311         if let zap_sheet {
    312             CustomizeZapView(state: damus_state, target: zap_sheet.target, lnurl: zap_sheet.lnurl)
    313         }
    314         else {
    315             zap_sheet_not_possible
    316         }
    317     }
    318 
    319     var zap_sheet_not_possible: some View {
    320         VStack(alignment: .center, spacing: 20) {
    321             Image(systemName: "bolt.trianglebadge.exclamationmark.fill")
    322                 .resizable()
    323                 .scaledToFit()
    324                 .frame(width: 70)
    325             Text("User not zappable", comment: "Headline indicating a user cannot be zapped")
    326                 .font(.headline)
    327             Text("This user cannot be zapped because they have not configured zaps on their account yet. Time to orange-pill?", comment: "Comment explaining why a user cannot be zapped.")
    328                 .multilineTextAlignment(.center)
    329                 .opacity(0.6)
    330             self.dm_button
    331         }
    332         .padding()
    333     }
    334 
    335     var dm_button: some View {
    336         let dm_model = damus_state.dms.lookup_or_create(target.pubkey)
    337         return VStack(alignment: .center, spacing: 10) {
    338             Button(
    339                 action: {
    340                     damus_state.nav.push(route: Route.DMChat(dms: dm_model))
    341                     dismiss()
    342                 },
    343                 label: {
    344                     Image("messages")
    345                         .profile_button_style(scheme: colorScheme)
    346                 }
    347             )
    348             .buttonStyle(NeutralButtonShape.circle.style)
    349             Text("Orange-pill", comment: "Button label that allows the user to start a direct message conversation with the user shown on-screen, to orange-pill them (i.e. help them to setup zaps)")
    350                 .foregroundStyle(.secondary)
    351                 .font(.caption)
    352         }
    353     }
    354 }
    355 
    356 extension View {
    357     func hideKeyboard() {
    358         let resign = #selector(UIResponder.resignFirstResponder)
    359         this_app.sendAction(resign, to: nil, from: nil, for: nil)
    360     }
    361 }
    362 
    363 
    364 
    365 fileprivate func test_zap_sheet() -> ZapSheet {
    366     let zap_target = ZapTarget.note(id: test_note.id, author: test_note.pubkey)
    367     let lnurl = ""
    368     return ZapSheet(target: zap_target, lnurl: lnurl)
    369 }
    370 
    371 #Preview {
    372     CustomizeZapView(state: test_damus_state, target: test_zap_sheet().target, lnurl: test_zap_sheet().lnurl)
    373         .frame(width: 400, height: 600)
    374 }
    375 
    376 #Preview {
    377     ZapSheetViewIfPossible(damus_state: test_damus_state, target: test_zap_sheet().target, lnurl: test_zap_sheet().lnurl)
    378         .frame(width: 400, height: 600)
    379 }
    380 
    381 #Preview {
    382     ZapSheetViewIfPossible(damus_state: test_damus_state, target: test_zap_sheet().target, lnurl: nil)
    383         .frame(width: 400, height: 600)
    384 }