PayView.swift (26906B)
1 // 2 // PayView.swift 3 // lightninglink 4 // 5 // Created by William Casarin on 2022-02-05. 6 // 7 8 import SwiftUI 9 import Combine 10 11 public struct Offer { 12 let offer: String 13 let amount: InvoiceAmount 14 let decoded: InvoiceDecode 15 } 16 17 public struct Invoice { 18 let invstr: String 19 let amount: InvoiceAmount 20 } 21 22 public enum ReadyInvoice { 23 case requested(RequestInvoice) 24 case direct(Invoice) 25 26 func amount() -> InvoiceAmount { 27 switch self { 28 case .direct(let inv): 29 return inv.amount 30 case .requested(let invreq): 31 return invreq.amount() 32 } 33 } 34 } 35 36 public enum RequestInvoice { 37 case lnurl(LNUrlPay) 38 case offer(Offer) 39 40 func amount() -> InvoiceAmount { 41 switch self { 42 case .lnurl(let lnurlp): 43 return lnurl_pay_invoice_amount(lnurlp) 44 case .offer(let offer): 45 return offer.amount 46 } 47 } 48 } 49 50 public struct PayAmount { 51 let tip: Int64? 52 let amount: Int64 53 54 func total() -> Int64 { 55 return amount + (tip ?? 0) 56 } 57 } 58 59 public struct FetchInvoiceReq { 60 let offer: String 61 let pay_amt: PayAmount? 62 let amount: InvoiceAmount 63 let quantity: Int? 64 let timeout: Int? 65 } 66 67 public enum TipSelection { 68 case none 69 case fifteen 70 case twenty 71 case twenty_five 72 } 73 74 public enum PayState { 75 case initial 76 case decoding(LNSocket?, DecodeType) 77 case decoded(DecodeType) 78 case ready(Invoice) 79 case invoice_request(RequestInvoice) 80 case auth(LNUrlAuth) 81 } 82 83 let default_placeholder = "10,000" 84 85 struct PayView: View { 86 let init_decode_type: DecodeType 87 let lnlink: LNLink 88 let rate: ExchangeRate? 89 90 let expiry_timer = Timer.publish(every: 1, on: .main, in: .default).autoconnect() 91 let pay_timeout_ms: Int32 = 100_000 92 93 @State var pay_result: Pay? 94 @State var state: PayState = .initial 95 @State var invoice: Decode? 96 @State var error: String? 97 @State var expiry_percent: Double? 98 @State var custom_amount_input: String = "" 99 @State var custom_amount_msats: Int64 = 0 100 @State var current_tip: TipSelection = .none 101 @State var paying: Bool = false 102 103 @Environment(\.presentationMode) var presentationMode 104 @Environment (\.colorScheme) var colorScheme: ColorScheme 105 106 init(decode: DecodeType, lnlink: LNLink, rate: ExchangeRate?) { 107 self.init_decode_type = decode 108 self.lnlink = lnlink 109 self.rate = rate 110 } 111 112 var successView: some View { 113 VStack() { 114 Text("Payment Success!").font(.largeTitle) 115 } 116 } 117 118 var failView: some View { 119 VStack() { 120 Text("Payment Failed").font(.largeTitle) 121 Text(self.error!) 122 } 123 } 124 125 private func dismiss() { 126 self.presentationMode.wrappedValue.dismiss() 127 } 128 129 var body: some View { 130 MainView() 131 } 132 133 func progress_color() -> Color { 134 guard let perc = expiry_percent else { 135 return Color.green 136 } 137 138 if perc < 0.25 { 139 return Color.red 140 } else if perc < 0.5 { 141 return Color.yellow 142 } 143 144 return Color.green 145 } 146 147 func MainView() -> some View { 148 return VStack(alignment: .hcentered) { 149 Text("Confirm Payment") 150 .font(.largeTitle) 151 .padding() 152 153 if self.expiry_percent != nil { 154 ProgressView(value: self.expiry_percent! * 100, total: 100) 155 .accentColor(progress_color()) 156 } 157 158 if self.invoice != nil { 159 let invoice = self.invoice! 160 if invoice.description() != nil { 161 Text(invoice.description()!) 162 .multilineTextAlignment(.center) 163 .padding() 164 } 165 166 if invoice.vendor() != nil { 167 Text(invoice.vendor()!) 168 .font(.callout) 169 .foregroundColor(.gray) 170 } 171 } 172 173 if self.invoice != nil && self.invoice!.thumbnail() != nil { 174 self.invoice!.thumbnail()! 175 .resizable() 176 .frame(width: 128, height: 128, alignment: .center) 177 .clipShape(Circle()) 178 .overlay(Circle().stroke(Color.black, lineWidth: 4)) 179 .padding() 180 } 181 182 Spacer() 183 184 // Middle area 185 let ready_invoice = is_ready(state) 186 if ready_invoice != nil { 187 amount_view_inv(ready_invoice!.amount()) 188 } 189 190 Text("\(self.error ?? "")") 191 .foregroundColor(Color.red) 192 193 if self.should_show_progress() { 194 ProgressView() 195 .progressViewStyle(.circular) 196 } 197 198 Spacer() 199 200 // Bottom area 201 if !self.paying { 202 HStack { 203 Button("Cancel") { 204 self.dismiss() 205 } 206 .foregroundColor(Color.red) 207 .font(.title) 208 209 Spacer() 210 if should_show_confirmation(ready_invoice?.amount()) { 211 Button("Confirm") { 212 handle_confirm(ln: nil) 213 } 214 .foregroundColor(Color.green) 215 .font(.title) 216 } 217 } 218 } 219 } 220 .padding() 221 .onAppear() { 222 handle_state_change() 223 } 224 .onReceive(self.expiry_timer) { _ in 225 update_expiry_percent() 226 } 227 } 228 229 func should_show_confirmation(_ amt: InvoiceAmount?) -> Bool { 230 if amt != nil && is_any_amount(amt!) && self.custom_amount_msats == 0 { 231 return false 232 } 233 return should_show_confirm(self.state) 234 } 235 236 func should_show_progress() -> Bool { 237 return self.paying || (self.error == nil && is_ready(self.state) == nil) 238 } 239 240 func tip_percent(_ tip: TipSelection) { 241 if tip == self.current_tip { 242 self.current_tip = .none 243 self.custom_amount_msats = 0 244 return 245 } 246 247 self.current_tip = tip 248 let percent = tip_value(tip) 249 250 if tip == .none { 251 self.custom_amount_msats = 0 252 return 253 } 254 guard let invoice = self.invoice else { 255 return 256 } 257 guard let amount_msat = invoice.amount_msat() else { 258 return 259 } 260 261 self.custom_amount_msats = Int64((Double(amount_msat) * percent)) 262 } 263 264 func tip_view() -> some View { 265 Group { 266 Text("Tip?") 267 HStack { 268 let unsel_c: Color = .primary 269 let sel_c: Color = .blue 270 271 Button("15%") { 272 tip_percent(.fifteen) 273 } 274 .buttonStyle(.bordered) 275 .foregroundColor(current_tip == .fifteen ? sel_c: unsel_c) 276 277 Button("20%") { 278 tip_percent(.twenty) 279 } 280 .buttonStyle(.bordered) 281 .foregroundColor(current_tip == .twenty ? sel_c: unsel_c) 282 283 Button("25%") { 284 tip_percent(.twenty_five) 285 } 286 .buttonStyle(.bordered) 287 .foregroundColor(current_tip == .twenty_five ? sel_c: unsel_c) 288 } 289 .padding() 290 } 291 } 292 293 func amount_view_inv(_ amt: InvoiceAmount) -> some View { 294 Group { 295 if self.paying { 296 Text("Paying...") 297 } else { 298 Text("Pay") 299 } 300 301 switch amt { 302 case .min(let min_amt): 303 amount_view(min_amt + self.custom_amount_msats, rate: self.rate) 304 305 Text("\(render_amount_msats(self.custom_amount_msats)) tipped") 306 .font(.callout) 307 .foregroundColor(.gray) 308 309 if !self.paying { 310 Spacer() 311 tip_view() 312 } 313 314 case .range(let min_amt, let max_amt): 315 if self.paying { 316 let amt = self.custom_amount_msats 317 amount_view(amt, rate: self.rate) 318 } else { 319 AmountInput(text: $custom_amount_input, placeholder: default_placeholder) { result in 320 if let str = result.msats_str { 321 self.custom_amount_input = str 322 } 323 if let msats = result.msats { 324 self.custom_amount_msats = msats 325 } 326 327 if self.custom_amount_input != "" { 328 if self.custom_amount_msats < min_amt { 329 self.error = "Amount not allowed, must be higher than \(render_amount_msats(min_amt))" 330 } else if self.custom_amount_msats > max_amt { 331 self.error = "Amount not allowed, must be lower than \(render_amount_msats(max_amt))" 332 } else { 333 if self.error != nil && self.error!.starts(with: "Amount not allowed") { 334 self.error = nil 335 } 336 } 337 } 338 } 339 } 340 341 case .any: 342 if self.paying { 343 let amt = self.custom_amount_msats 344 amount_view(amt, rate: self.rate) 345 } else { 346 Form { 347 AmountInput(text: $custom_amount_input, placeholder: default_placeholder) { parsed in 348 if let str = parsed.msats_str { 349 self.custom_amount_input = str 350 } 351 if let msats = parsed.msats { 352 self.custom_amount_msats = msats 353 } 354 } 355 } 356 .frame(height: 100) 357 } 358 359 case .amount(let amt): 360 amount_view(amt, rate: self.rate) 361 } 362 363 if !self.paying && self.custom_amount_input != "", let msats = self.custom_amount_msats { 364 if let rate = self.rate { 365 Text("\(msats_to_fiat(msats: msats, xr: rate))") 366 .foregroundColor(.gray) 367 } 368 } 369 } 370 } 371 372 func confirm_pay(ln: LNSocket?, inv: String, pay_amt: PayAmount?, timeout_ms: Int32, description: String?) { 373 let res = confirm_payment(ln: ln, lnlink: self.lnlink, bolt11: inv, pay_amt: pay_amt, timeout_ms: timeout_ms, description: description) 374 switch res { 375 case .left(let err): 376 self.paying = false 377 self.error = err 378 379 case .right(let pay): 380 print(pay) 381 DispatchQueue.main.async { 382 self.dismiss() 383 NotificationCenter.default.post(name: .sentPayment, object: pay) 384 } 385 } 386 } 387 388 func get_pay_amount(_ amt: InvoiceAmount) -> PayAmount? { 389 return get_pay_amount_from_input(amt, input_amount: self.custom_amount_msats) 390 } 391 392 func handle_confirm_lnurl(ln mln: LNSocket?, lnurlp: LNUrlPay) { 393 let lnurl_amt = lnurl_pay_invoice_amount(lnurlp) 394 guard let pay_amt = get_pay_amount(lnurl_amt) else { 395 self.error = "Invalid payment amount for lnurl" 396 return 397 } 398 self.paying = true 399 400 lnurl_fetchinvoice(lnurlp: lnurlp, amount: pay_amt.amount) { 401 switch $0 { 402 case .left(let err): 403 self.error = err.reason 404 self.paying = false 405 case .right(let lnurl_invoice): 406 guard let ret_inv = parseInvoiceString(lnurl_invoice.pr) else { 407 self.error = "Invalid lnurl invoice" 408 self.paying = false 409 return 410 } 411 switch ret_inv { 412 case .invoice(let amt, let invstr): 413 if !pay_amount_matches(pay_amt: pay_amt, invoice_amount: amt) { 414 self.error = "Returned lnurl invoice doesn't match expected amount" 415 self.paying = false 416 return 417 } 418 419 DispatchQueue.global(qos: .background).async { 420 confirm_pay(ln: mln, inv: invstr, pay_amt: nil, timeout_ms: pay_timeout_ms, description: lnurlp.metadata) 421 } 422 case .offer: 423 self.error = "Got an offer from a lnurl pay request? What?" 424 self.paying = false 425 return 426 case .lnurl: 427 self.error = "Got another lnurl from an lnurl pay request? What?" 428 self.paying = false 429 return 430 } 431 } 432 } 433 } 434 435 func handle_confirm_offer(ln mln: LNSocket?, offer: Offer) { 436 guard let pay_amt = get_pay_amount(offer.amount) else { 437 self.error = "Expected payment amount for bolt12" 438 return 439 } 440 let req = fetchinvoice_req_from_offer( 441 offer: offer.decoded, 442 offer_str: offer.offer, 443 pay_amt: pay_amt) 444 switch req { 445 case .left(let err): 446 self.error = err 447 case .right(let req): 448 let token = self.lnlink.token 449 self.paying = true 450 DispatchQueue.global(qos: .background).async { 451 let ln = mln ?? LNSocket() 452 if mln == nil { 453 guard ln.connect_and_init(node_id: self.lnlink.node_id, host: self.lnlink.host) else { 454 self.paying = false 455 self.error = "Connection failed when fetching invoice" 456 return 457 } 458 } 459 switch rpc_fetchinvoice(ln: ln, token: token, req: req) { 460 case .failure(let err): 461 self.paying = false 462 self.error = err.description 463 case .success(let fetch_invoice): 464 confirm_pay(ln: ln, inv: fetch_invoice.invoice, pay_amt: nil, timeout_ms: pay_timeout_ms, description: nil) 465 } 466 } 467 } 468 } 469 470 func handle_confirm_auth(ln mln: LNSocket?, auth: LNUrlAuth) { 471 } 472 473 func handle_confirm(ln mln: LNSocket?) { 474 // clear last error on confirm 475 self.error = nil 476 477 switch self.state { 478 case .auth(let auth): 479 return handle_confirm_auth(ln: mln, auth: auth) 480 481 case .invoice_request(let reqinv): 482 switch reqinv { 483 case .offer(let offer): 484 return handle_confirm_offer(ln: mln, offer: offer) 485 case .lnurl(let lnurlp): 486 return handle_confirm_lnurl(ln: mln, lnurlp: lnurlp) 487 } 488 489 case .ready(let invoice): 490 let pay_amt = get_pay_amount(invoice.amount) 491 self.paying = true 492 DispatchQueue.global(qos: .background).async { 493 confirm_pay(ln: mln, inv: invoice.invstr, pay_amt: pay_amt, timeout_ms: pay_timeout_ms, description: nil) 494 } 495 496 case .initial: fallthrough 497 case .decoding: fallthrough 498 case .decoded: 499 self.error = "Invalid state: \(self.state)" 500 } 501 } 502 503 func is_tip_selected(_ tip: TipSelection) -> Bool { 504 return tip == self.current_tip 505 } 506 507 func switch_state(_ state: PayState) { 508 self.state = state 509 handle_state_change() 510 } 511 512 func handle_state_change() { 513 switch self.state { 514 case .auth: 515 break 516 case .ready: 517 break 518 case .invoice_request: 519 break 520 case .initial: 521 switch_state(.decoding(nil, self.init_decode_type)) 522 case .decoding(let ln, let decode): 523 DispatchQueue.global(qos: .background).async { 524 self.handle_decode(ln, decode: decode) 525 } 526 case .decoded: 527 break 528 } 529 530 } 531 532 func handle_offer(ln: LNSocket, decoded: InvoiceDecode, inv: String) { 533 switch handle_bolt12_offer(ln: ln, decoded: decoded, inv: inv) { 534 case .right(let state): 535 self.invoice = .invoice(decoded) 536 switch_state(state) 537 case .left(let err): 538 self.error = err 539 } 540 } 541 542 func handle_lnurl_payview(ln: LNSocket?, lnurlp: LNUrlPay) { 543 let decode = decode_lnurlp_metadata(lnurlp) 544 self.invoice = .lnurlp(decode) 545 546 switch_state(.invoice_request(.lnurl(lnurlp))) 547 } 548 549 func handle_lnurl_auth(ln: LNSocket, auth: LNUrlAuth) { 550 switch_state(.auth(auth)) 551 } 552 553 func handle_decode(_ oldln: LNSocket?, decode: DecodeType) { 554 let ln = oldln ?? LNSocket() 555 if oldln == nil { 556 guard ln.connect_and_init(node_id: self.lnlink.node_id, host: self.lnlink.host) else { 557 return 558 } 559 } 560 561 var inv = "" 562 switch decode { 563 case .offer(let s): 564 inv = s 565 case .invoice(_, let s): 566 inv = s 567 case .lnurl(let lnurl): 568 handle_lnurl(lnurl) { lnurl in 569 switch lnurl { 570 case .payRequest(let pay): 571 self.handle_lnurl_payview(ln: ln, lnurlp: pay) 572 return 573 case .none: 574 self.error = "Invalid lnurl" 575 } 576 } 577 return 578 } 579 580 switch rpc_decode(ln: ln, token: self.lnlink.token, inv: inv) { 581 case .failure(let fail): 582 self.error = fail.description 583 case .success(let decoded): 584 if decoded.type == "bolt12 offer" { 585 self.handle_offer(ln: ln, decoded: decoded, inv: inv) 586 587 } else if decoded.type == "bolt11 invoice" || decoded.type == "bolt12 invoice" { 588 var amount: InvoiceAmount = .any 589 if decoded.amount_msat != nil { 590 guard let amt = decoded.amount_msat else { 591 self.error = "invalid msat amount: \(decoded.amount_msat!)" 592 return 593 } 594 595 amount = .amount(amt.msat) 596 } 597 598 self.state = .ready(Invoice(invstr: inv, amount: amount)) 599 self.invoice = .invoice(decoded) 600 update_expiry_percent() 601 } else { 602 self.error = "unknown decoded type: \(decoded.type)" 603 } 604 } 605 606 } 607 608 func update_expiry_percent() { 609 if case let .invoice(invoice) = self.invoice { 610 guard let expiry = get_decode_expiry(invoice) else { 611 self.expiry_percent = nil 612 return 613 } 614 615 guard let created_at = invoice.created_at else { 616 self.expiry_percent = nil 617 return 618 } 619 620 let now = Int64(Date().timeIntervalSince1970) 621 let expires_at = created_at + expiry 622 623 guard expiry > 0 else { 624 self.expiry_percent = nil 625 return 626 } 627 628 guard now < expires_at else { 629 self.error = "Invoice expired" 630 self.expiry_percent = nil 631 return 632 } 633 634 guard now >= created_at else { 635 self.expiry_percent = 1 636 return 637 } 638 639 let prog = now - created_at 640 self.expiry_percent = 1.0 - (Double(prog) / Double(expiry)) 641 } 642 643 644 } 645 } 646 647 func fetchinvoice_req_from_offer(offer: InvoiceDecode, offer_str: String, pay_amt: PayAmount) -> Either<String, FetchInvoiceReq> { 648 649 var qty: Int? = nil 650 if offer.quantity_min != nil { 651 qty = offer.quantity_min! 652 } 653 654 // TODO: should we wait longer to fetch an invoice?? 655 let timeout = 10 656 657 if offer.amount_msat != nil { 658 return .right(.init( 659 offer: offer_str, 660 pay_amt: pay_amt, 661 amount: .any, 662 quantity: qty, 663 timeout: timeout 664 )) 665 } else { 666 let amount: InvoiceAmount = .amount(pay_amt.amount) 667 return .right(.init( 668 offer: offer_str, 669 pay_amt: pay_amt, 670 amount: amount, 671 quantity: qty, 672 timeout: timeout 673 )) 674 } 675 } 676 677 func parse_msat(_ s: String) -> Int64? { 678 let str = s.replacingOccurrences(of: "msat", with: "") 679 return Int64(str) 680 } 681 682 public enum Either<L, R> { 683 case left(L) 684 case right(R) 685 686 func mapError<L2>(mapper: (L) -> L2) -> Either<L2, R> { 687 switch self { 688 case .left(let l1): 689 return .left(mapper(l1)) 690 case .right(let r): 691 return .right(r) 692 } 693 } 694 } 695 696 func confirm_payment(ln mln: LNSocket?, lnlink: LNLink, bolt11: String, pay_amt: PayAmount?, timeout_ms: Int32, description: String?) -> Either<String, Pay> { 697 let ln = mln ?? LNSocket() 698 699 if mln == nil { 700 guard ln.connect_and_init(node_id: lnlink.node_id, host: lnlink.host) else { 701 return .left("Failed to connect, please try again!") 702 } 703 } 704 705 var amount_msat: Int64? = nil 706 if pay_amt != nil { 707 amount_msat = pay_amt!.amount + (pay_amt!.tip ?? 0) 708 } 709 710 let res = rpc_pay( 711 ln: ln, 712 token: lnlink.token, 713 bolt11: bolt11, 714 amount_msat: amount_msat, 715 timeout_ms: timeout_ms, 716 description: description 717 ) 718 719 switch res { 720 case .failure(let req_err): 721 // handle error 722 let errmsg = req_err.description 723 return .left(errmsg) 724 725 case .success(let pay): 726 return .right(pay) 727 } 728 } 729 730 func is_ready(_ state: PayState) -> ReadyInvoice? { 731 switch state { 732 case .ready(let invoice): 733 return .direct(invoice) 734 case .invoice_request(let invreq): 735 return .requested(invreq) 736 case .auth: fallthrough 737 case .initial: fallthrough 738 case .decoding: fallthrough 739 case .decoded: 740 return nil 741 } 742 } 743 744 745 func render_amount(_ amt: InvoiceAmount) -> String { 746 switch amt { 747 case .any: 748 return "Enter amount" 749 case .range(let min_amt, let max_amt): 750 return "\(render_amount_msats(min_amt)) to \(render_amount_msats(max_amt))" 751 case .amount(let amt): 752 return "\(render_amount_msats(amt))?" 753 case .min(let min): 754 return "\(render_amount_msats(min))?" 755 } 756 } 757 758 func render_amount_msats_sep(_ amount: Int64) -> (String, String) { 759 let formatter = NumberFormatter() 760 formatter.numberStyle = .decimal 761 762 if amount < 1000 { 763 let amt_str = formatter.string(from: NSNumber(value: amount))! 764 return (amt_str, amount == 1 ? "msat" : "msats") 765 } 766 767 let amt_str = formatter.string(from: NSNumber(value: amount / 1000))! 768 return (amt_str, (amount/1000) == 1 ? "sat" : "sats") 769 } 770 771 func render_amount_msats(_ amount: Int64) -> String { 772 let res = render_amount_msats_sep(amount) 773 return "\(res.0) \(res.1)" 774 } 775 776 /* 777 struct PayView_Previews: PreviewProvider { 778 @Binding var invoice: Invoice? 779 780 static var previews: some View { 781 PayView(invoice: self.$invoice) 782 } 783 } 784 */ 785 786 func handle_bolt12_offer(ln: LNSocket, decoded: InvoiceDecode, inv: String) -> Either<String, PayState> { 787 if decoded.amount_msat != nil { 788 guard let min_amt = decoded.amount_msat else { 789 return .left("Error parsing amount_msat: '\(decoded.amount_msat!)'") 790 } 791 let offer = Offer(offer: inv, amount: .min(min_amt.msat), decoded: decoded) 792 return .right(.invoice_request(.offer(offer))) 793 } else { 794 let offer = Offer(offer: inv, amount: .any, decoded: decoded) 795 return .right(.invoice_request(.offer(offer))) 796 } 797 } 798 799 800 func should_show_confirm(_ state: PayState) -> Bool { 801 switch state { 802 case .ready: fallthrough 803 case .auth: fallthrough 804 case .invoice_request: 805 return true 806 807 case .decoded: fallthrough 808 case .initial: fallthrough 809 case .decoding: 810 return false 811 } 812 } 813 814 815 func tip_value(_ tip: TipSelection) -> Double { 816 switch tip { 817 case .none: return 0 818 case .fifteen: return 0.15 819 case .twenty: return 0.2 820 case .twenty_five: return 0.25 821 } 822 } 823 824 func is_any_amount(_ amt: InvoiceAmount) -> Bool { 825 switch amt { 826 case .any: 827 return true 828 default: 829 return false 830 } 831 } 832 833 func lnurl_pay_invoice_amount(_ lnurlp: LNUrlPay) -> InvoiceAmount { 834 let min_amt = Int64(lnurlp.minSendable ?? 1) 835 let max_amt = Int64(lnurlp.maxSendable ?? 2100000000000000000) 836 return .range(min_amt, max_amt) 837 } 838 839 func get_pay_amount_from_input(_ amt: InvoiceAmount, input_amount: Int64) -> PayAmount? { 840 switch amt { 841 case .min(let min_amt): 842 return PayAmount(tip: input_amount, amount: min_amt) 843 case .range: 844 return PayAmount(tip: 0, amount: input_amount) 845 case .any: 846 return PayAmount(tip: 0, amount: input_amount) 847 case .amount: 848 return nil 849 } 850 } 851 852 853 func pay_amount_matches(pay_amt: PayAmount, invoice_amount: InvoiceAmount) -> Bool 854 { 855 switch invoice_amount { 856 case .amount(let amt): 857 if pay_amt.total() == amt { 858 return true 859 } 860 case .range(let min_amt, let max_amt): 861 if pay_amt.total() < min_amt { 862 return false 863 } 864 865 if pay_amt.total() > max_amt { 866 return false 867 } 868 869 return true 870 case .min(let min): 871 if pay_amt.total() < min { 872 return false 873 } 874 875 return true 876 877 case .any: 878 879 return true 880 } 881 882 return false 883 } 884 885 886 887 func amount_view(_ msats: Int64, rate mrate: ExchangeRate?) -> some View { 888 Group { 889 HStack { 890 let sep = render_amount_msats_sep(msats) 891 Text(sep.0) 892 .font(.largeTitle) 893 .fontWeight(.bold) 894 .alignmentGuide(.hcentered) { 895 $0.width / 2.0 896 } 897 898 Text(sep.1) 899 .font(.subheadline) 900 } 901 902 if let rate = mrate { 903 Text("\(msats_to_fiat(msats: msats, xr: rate))") 904 .font(.footnote) 905 .foregroundColor(.gray) 906 } 907 } 908 } 909 910 extension HorizontalAlignment { 911 private enum HCenterAlignment: AlignmentID { 912 static func defaultValue(in dimensions: ViewDimensions) -> CGFloat { 913 return dimensions[HorizontalAlignment.center] 914 } 915 } 916 static let hcentered = HorizontalAlignment(HCenterAlignment.self) 917 }