Initial commit

This commit is contained in:
Wira Basalamah
2026-04-24 04:55:24 +07:00
commit 8f0b001501
128 changed files with 9366 additions and 0 deletions

670
Emoney Info/HomeView.swift Normal file
View File

@ -0,0 +1,670 @@
import UIKit
import GoogleMobileAds
import Toast_Swift
final class HomeViewController: UIViewController {
// MARK: - Public state
var balanceText: String = "Rp 0" {
didSet { balanceLabel.text = balanceText }
}
var cardTypeText: String = "E-Money Card" {
didSet { cardTypeLabel.text = cardTypeText }
}
var lastTransaction: LastTransactionItem? {
didSet { configureLastTransaction() }
}
// MARK: - Callbacks
var onScanTapped: (() -> Void)?
var onViewHistoryTapped: (() -> Void)?
var onSettingsTapped: (() -> Void)?
// Diisi setelah scan berhasil, dipakai oleh SceneDelegate untuk history
var latestRiwayatList: [RiwayatCard] = [] {
didSet { updateViewHistoryButtonState() }
}
var latestCardNumber: String = "" // raw card number for PDF export
// MARK: - UI Elements
private let scrollView = UIScrollView()
private let contentView = UIView()
// Header
private let appNameLabel = UILabel()
private let settingsButton = UIButton(type: .system)
// Balance
private let availableLabel = UILabel()
private let balanceLabel = UILabel()
// Card
private let cardView = UIView()
private let cardGradient = CAGradientLayer()
private let nfcIconView = UIImageView()
private let tapCardLabel = UILabel()
private let cardNumberLabel = UILabel()
private let copyButton = UIButton(type: .system)
private let cardTypeLabel = UILabel()
// Raw card number stored after scan for re-formatting on setting change
private var rawCardNumber: String = ""
// Instruction
private let tapHereLabel = UILabel()
private let tapHintLabel = UILabel()
// Scan button
private let scanButton = UIButton(type: .system)
// Ads
private let promoCard = UIView()
private var bannerView = GADBannerView()
// Dynamic layout: toggle based on ad load state
private var lastTxTopNoAd: NSLayoutConstraint!
private var lastTxTopWithAd: NSLayoutConstraint!
// Last transaction
private let lastTxHeader = UILabel()
private let lastTxCard = UIView()
private let txIconView = UIView()
private let txIconImage = UIImageView()
private let txTitleLabel = UILabel()
private let txDateLabel = UILabel()
private let txAmountLabel = UILabel()
private let txStatusLabel = UILabel()
// Footer link
private let viewHistoryButton = UIButton(type: .system)
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Theme.Color.background
setupScrollView()
setupHeader()
setupBalanceSection()
setupCard()
setupInstruction()
setupScanButton()
setupPromo()
setupLastTransaction()
setupViewHistoryButton()
setupConstraints()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
NotificationCenter.default.addObserver(
self,
selector: #selector(onSettingChanged),
name: Notification.Name("refreshScreen"),
object: nil
)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
NotificationCenter.default.removeObserver(self, name: Notification.Name("refreshScreen"), object: nil)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
cardGradient.frame = cardView.bounds
}
// MARK: - Setup
private func setupScrollView() {
scrollView.showsVerticalScrollIndicator = false
scrollView.translatesAutoresizingMaskIntoConstraints = false
contentView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scrollView)
scrollView.addSubview(contentView)
}
private func setupHeader() {
appNameLabel.text = "Emoney Info"
appNameLabel.font = Theme.Font.title(weight: .bold)
appNameLabel.textColor = Theme.Color.textPrimary
let gearImage = UIImage(systemName: "gearshape", withConfiguration:
UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))
settingsButton.setImage(gearImage, for: .normal)
settingsButton.tintColor = Theme.Color.textPrimary
settingsButton.addTarget(self, action: #selector(settingsTapped), for: .touchUpInside)
[appNameLabel, settingsButton].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview($0)
}
}
private func setupBalanceSection() {
availableLabel.text = L10n.availableBalance
availableLabel.font = Theme.Font.caption(weight: .semibold)
availableLabel.textColor = Theme.Color.textSecondary
availableLabel.letterSpacing(1.5)
balanceLabel.text = "Rp 0"
balanceLabel.font = .systemFont(ofSize: 36, weight: .bold)
balanceLabel.textColor = Theme.Color.textPrimary
[availableLabel, balanceLabel].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview($0)
}
}
private func setupCard() {
cardView.layer.cornerRadius = 24
cardView.clipsToBounds = true
cardGradient.colors = [
UIColor(red: 0.48, green: 0.83, blue: 0.82, alpha: 1).cgColor,
UIColor(red: 0.36, green: 0.49, blue: 0.48, alpha: 1).cgColor
]
cardGradient.startPoint = CGPoint(x: 0, y: 0)
cardGradient.endPoint = CGPoint(x: 1, y: 1)
cardView.layer.insertSublayer(cardGradient, at: 0)
let nfcConfig = UIImage.SymbolConfiguration(pointSize: 48, weight: .medium)
nfcIconView.image = UIImage(systemName: "wave.3.right.circle.fill", withConfiguration: nfcConfig)
nfcIconView.tintColor = .white.withAlphaComponent(0.9)
nfcIconView.contentMode = .scaleAspectFit
tapCardLabel.text = L10n.cardTapInstruction
tapCardLabel.font = Theme.Font.caption(weight: .bold)
tapCardLabel.textColor = .white.withAlphaComponent(0.85)
tapCardLabel.letterSpacing(2)
cardTypeLabel.text = cardTypeText
cardTypeLabel.font = Theme.Font.caption(weight: .regular)
cardTypeLabel.textColor = .white.withAlphaComponent(0.7)
cardNumberLabel.font = .systemFont(ofSize: 11, weight: .medium)
cardNumberLabel.textColor = .white.withAlphaComponent(0.85)
cardNumberLabel.isHidden = true
let copyConfig = UIImage.SymbolConfiguration(pointSize: 11, weight: .medium)
copyButton.setImage(UIImage(systemName: "doc.on.doc", withConfiguration: copyConfig), for: .normal)
copyButton.tintColor = .white.withAlphaComponent(0.75)
copyButton.isHidden = true
copyButton.addTarget(self, action: #selector(copyCardNumber), for: .touchUpInside)
[cardView, nfcIconView, tapCardLabel, cardNumberLabel, copyButton, cardTypeLabel].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
contentView.addSubview(cardView)
cardView.addSubview(nfcIconView)
cardView.addSubview(tapCardLabel)
cardView.addSubview(cardNumberLabel)
cardView.addSubview(copyButton)
cardView.addSubview(cardTypeLabel)
}
private func setupInstruction() {
tapHereLabel.isHidden = true
tapHintLabel.text = L10n.tapCardHint
tapHintLabel.font = Theme.Font.body(weight: .regular)
tapHintLabel.textColor = Theme.Color.textSecondary
tapHintLabel.textAlignment = .center
tapHintLabel.numberOfLines = 2
[tapHereLabel, tapHintLabel].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview($0)
}
}
private func setupScanButton() {
scanButton.setTitle(L10n.checkBalance, for: .normal)
scanButton.titleLabel?.font = Theme.Font.body(weight: .semibold)
scanButton.setTitleColor(.white, for: .normal)
scanButton.backgroundColor = Theme.Color.secondary
scanButton.layer.cornerRadius = 14
scanButton.addTarget(self, action: #selector(scanTapped), for: .touchUpInside)
scanButton.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(scanButton)
}
private func setupPromo() {
// TODO: Replace ad unit ID before release
let adUnitID = "ca-app-pub-3389368171983845/5892672309"
let adSize = GADCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(
UIScreen.main.bounds.width - 48 // matches horizontal padding 24pt each side
)
bannerView = GADBannerView(adSize: adSize)
bannerView.adUnitID = adUnitID
bannerView.rootViewController = self
bannerView.delegate = self
bannerView.translatesAutoresizingMaskIntoConstraints = false
promoCard.backgroundColor = .clear
promoCard.translatesAutoresizingMaskIntoConstraints = false
promoCard.clipsToBounds = true
promoCard.isHidden = true // shown only when ad loads successfully
promoCard.addSubview(bannerView)
contentView.addSubview(promoCard)
NSLayoutConstraint.activate([
bannerView.topAnchor.constraint(equalTo: promoCard.topAnchor),
bannerView.leadingAnchor.constraint(equalTo: promoCard.leadingAnchor),
bannerView.trailingAnchor.constraint(equalTo: promoCard.trailingAnchor),
bannerView.bottomAnchor.constraint(equalTo: promoCard.bottomAnchor),
bannerView.heightAnchor.constraint(equalToConstant: CGFloat(adSize.size.height))
])
// Ad is loaded externally via loadBannerAd() after GADMobileAds SDK has started.
}
func loadBannerAd() {
bannerView.load(GADRequest())
}
private func setupLastTransaction() {
lastTxHeader.text = L10n.lastTransaction
lastTxHeader.font = Theme.Font.subtitle(weight: .bold)
lastTxHeader.textColor = Theme.Color.textPrimary
lastTxHeader.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(lastTxHeader)
lastTxCard.backgroundColor = Theme.Color.card
lastTxCard.layer.cornerRadius = 16
lastTxCard.layer.shadowColor = UIColor.black.cgColor
lastTxCard.layer.shadowOpacity = 0.06
lastTxCard.layer.shadowOffset = CGSize(width: 0, height: 2)
lastTxCard.layer.shadowRadius = 8
lastTxCard.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(lastTxCard)
// Transaction icon
txIconView.backgroundColor = Theme.Color.background
txIconView.layer.cornerRadius = 12
txIconImage.tintColor = Theme.Color.secondary
txIconImage.contentMode = .scaleAspectFit
txIconImage.translatesAutoresizingMaskIntoConstraints = false
txIconView.addSubview(txIconImage)
txTitleLabel.font = Theme.Font.body(weight: .semibold)
txTitleLabel.textColor = Theme.Color.textPrimary
txDateLabel.font = Theme.Font.caption(weight: .regular)
txDateLabel.textColor = Theme.Color.textSecondary
txAmountLabel.font = Theme.Font.body(weight: .bold)
txAmountLabel.textColor = UIColor.systemRed
txAmountLabel.textAlignment = .right
txStatusLabel.font = Theme.Font.caption(weight: .semibold)
txStatusLabel.textColor = Theme.Color.success
txStatusLabel.textAlignment = .right
let leftStack = UIStackView(arrangedSubviews: [txTitleLabel, txDateLabel])
leftStack.axis = .vertical
leftStack.spacing = 2
let rightStack = UIStackView(arrangedSubviews: [txAmountLabel, txStatusLabel])
rightStack.axis = .vertical
rightStack.spacing = 2
rightStack.alignment = .trailing
let hStack = UIStackView(arrangedSubviews: [txIconView, leftStack, rightStack])
hStack.axis = .horizontal
hStack.spacing = 12
hStack.alignment = .center
[txIconView, txIconImage, hStack, leftStack, rightStack].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
lastTxCard.addSubview(hStack)
NSLayoutConstraint.activate([
txIconImage.centerXAnchor.constraint(equalTo: txIconView.centerXAnchor),
txIconImage.centerYAnchor.constraint(equalTo: txIconView.centerYAnchor),
txIconImage.widthAnchor.constraint(equalToConstant: 20),
txIconImage.heightAnchor.constraint(equalToConstant: 20),
txIconView.widthAnchor.constraint(equalToConstant: 44),
txIconView.heightAnchor.constraint(equalToConstant: 44),
hStack.topAnchor.constraint(equalTo: lastTxCard.topAnchor, constant: 16),
hStack.leadingAnchor.constraint(equalTo: lastTxCard.leadingAnchor, constant: 16),
hStack.trailingAnchor.constraint(equalTo: lastTxCard.trailingAnchor, constant: -16),
hStack.bottomAnchor.constraint(equalTo: lastTxCard.bottomAnchor, constant: -16),
rightStack.widthAnchor.constraint(greaterThanOrEqualToConstant: 80)
])
configureLastTransaction()
}
private func setupViewHistoryButton() {
viewHistoryButton.setTitle("\(L10n.viewFullHistory)", for: .normal)
viewHistoryButton.titleLabel?.font = Theme.Font.body(weight: .semibold)
viewHistoryButton.addTarget(self, action: #selector(viewHistoryTapped), for: .touchUpInside)
viewHistoryButton.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(viewHistoryButton)
updateViewHistoryButtonState()
}
private func updateViewHistoryButtonState() {
let hasData = !latestRiwayatList.isEmpty
viewHistoryButton.isEnabled = hasData
viewHistoryButton.setTitleColor(hasData ? Theme.Color.secondary : Theme.Color.textSecondary, for: .normal)
viewHistoryButton.alpha = hasData ? 1.0 : 0.4
}
// MARK: - Constraints
private func setupConstraints() {
NSLayoutConstraint.activate([
// ScrollView
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
// ContentView
contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
// Header
appNameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20),
appNameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
settingsButton.centerYAnchor.constraint(equalTo: appNameLabel.centerYAnchor),
settingsButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24),
settingsButton.widthAnchor.constraint(equalToConstant: 32),
settingsButton.heightAnchor.constraint(equalToConstant: 32),
// Balance
availableLabel.topAnchor.constraint(equalTo: appNameLabel.bottomAnchor, constant: 28),
availableLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
balanceLabel.topAnchor.constraint(equalTo: availableLabel.bottomAnchor, constant: 4),
balanceLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
// Card
cardView.topAnchor.constraint(equalTo: balanceLabel.bottomAnchor, constant: 20),
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24),
cardView.heightAnchor.constraint(equalToConstant: 160),
nfcIconView.centerXAnchor.constraint(equalTo: cardView.centerXAnchor),
nfcIconView.centerYAnchor.constraint(equalTo: cardView.centerYAnchor, constant: -10),
nfcIconView.widthAnchor.constraint(equalToConstant: 60),
nfcIconView.heightAnchor.constraint(equalToConstant: 60),
tapCardLabel.topAnchor.constraint(equalTo: nfcIconView.bottomAnchor, constant: 8),
tapCardLabel.centerXAnchor.constraint(equalTo: cardView.centerXAnchor),
// card number + copy bottom left
cardNumberLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -14),
cardNumberLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16),
copyButton.centerYAnchor.constraint(equalTo: cardNumberLabel.centerYAnchor),
copyButton.leadingAnchor.constraint(equalTo: cardNumberLabel.trailingAnchor, constant: 6),
copyButton.widthAnchor.constraint(equalToConstant: 20),
copyButton.heightAnchor.constraint(equalToConstant: 20),
// card type bottom right
cardTypeLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -14),
cardTypeLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16),
// Instruction
tapHereLabel.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 20),
tapHereLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
tapHintLabel.topAnchor.constraint(equalTo: tapHereLabel.bottomAnchor, constant: 6),
tapHintLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
tapHintLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 40),
tapHintLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -40),
// Scan Button
scanButton.topAnchor.constraint(equalTo: tapHintLabel.bottomAnchor, constant: 20),
scanButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
scanButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24),
scanButton.heightAnchor.constraint(equalToConstant: 52),
// Promo
promoCard.topAnchor.constraint(equalTo: scanButton.bottomAnchor, constant: 28),
promoCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
promoCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24),
// Last Transaction leading (top is toggled dynamically)
lastTxHeader.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
lastTxCard.topAnchor.constraint(equalTo: lastTxHeader.bottomAnchor, constant: 12),
lastTxCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
lastTxCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24),
// View History
viewHistoryButton.topAnchor.constraint(equalTo: lastTxCard.bottomAnchor, constant: 12),
viewHistoryButton.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
viewHistoryButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -140)
])
// Dynamic top constraints for Last Transaction toggled by ad load state
lastTxTopNoAd = lastTxHeader.topAnchor.constraint(equalTo: scanButton.bottomAnchor, constant: 28)
lastTxTopWithAd = lastTxHeader.topAnchor.constraint(equalTo: promoCard.bottomAnchor, constant: 28)
lastTxTopNoAd.isActive = true // default: no ad
}
// MARK: - Actions
@objc private func scanTapped() {
latestRiwayatList = []
latestCardNumber = ""
lastTransaction = nil
onScanTapped?()
}
@objc private func viewHistoryTapped() {
onViewHistoryTapped?()
}
@objc private func settingsTapped() {
onSettingsTapped?()
}
@objc private func copyCardNumber() {
guard !rawCardNumber.isEmpty else { return }
UIPasteboard.general.string = rawCardNumber
var style = ToastStyle()
style.backgroundColor = Theme.Color.primary
style.messageColor = .white
style.messageFont = Theme.Font.body(weight: .semibold)
style.cornerRadius = 12
view.makeToast(L10n.copiedToClipboard, duration: 2.0, position: .top, style: style)
}
@objc private func onSettingChanged() {
guard !rawCardNumber.isEmpty else { return }
updateCardNumberDisplay()
}
// MARK: - Card number display
private func updateCardNumberDisplay() {
// "Show Card Number on Home" toggle maps to key "masked"
// isOn = true show full number
// isOn = false mask first 12 digits
let showFull = UserDefaults.standard.bool(forKey: "masked")
cardNumberLabel.text = showFull ? rawCardNumber.formatCardNumber() : rawCardNumber.maskFirst12()
cardNumberLabel.isHidden = false
copyButton.isHidden = false
}
// MARK: - Data
private func configureLastTransaction() {
guard let tx = lastTransaction else {
txTitleLabel.text = ""
txDateLabel.text = ""
txAmountLabel.text = ""
txStatusLabel.text = ""
txIconImage.image = UIImage(systemName: "creditcard.fill")
return
}
let hasPlace = !tx.place.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
// Title: gunakan place jika ada, fallback ke status topup/payment
txTitleLabel.text = hasPlace ? tx.place : (tx.isCredit ? L10n.topup : L10n.payment)
txTitleLabel.numberOfLines = 2
// Icon: tram hanya untuk KMT, selain itu pakai arrow.down/creditcard
let iconName: String
if tx.isKMT {
iconName = "tram.fill"
} else {
iconName = tx.isCredit ? "arrow.down.circle.fill" : "creditcard.fill"
}
txIconImage.image = UIImage(systemName: iconName)
txDateLabel.text = tx.date
txAmountLabel.text = tx.amount
txStatusLabel.text = tx.status.uppercased()
txStatusLabel.textColor = tx.isCredit ? Theme.Color.success : UIColor.systemRed
txAmountLabel.textColor = tx.isCredit ? Theme.Color.success : UIColor.systemRed
if (!hasPlace){
txStatusLabel.isHidden = true
}
}
}
// MARK: - Data Model
struct LastTransactionItem {
let place: String // locationName, bisa kosong
let date: String
let amount: String
let status: String
let isCredit: Bool
let isKMT: Bool
}
// MARK: - GADBannerViewDelegate
extension HomeViewController: GADBannerViewDelegate {
func bannerViewDidReceiveAd(_ bannerView: GADBannerView) {
lastTxTopNoAd.isActive = false
lastTxTopWithAd.isActive = true
UIView.animate(withDuration: 0.3) {
self.promoCard.isHidden = false
self.view.layoutIfNeeded()
}
}
func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) {
lastTxTopWithAd.isActive = false
lastTxTopNoAd.isActive = true
UIView.animate(withDuration: 0.3) {
self.promoCard.isHidden = true
self.view.layoutIfNeeded()
}
}
}
// MARK: - ApduCallback
import CoreNFC
extension HomeViewController: ApduCallback {
func felicaConnected(unifiedNfcApi: UnifiedNfcApi, tag:NFCFeliCaTag) {
debugLog("felica nih.")
unifiedNfcApi.checkFelicaCard(tag: tag)
}
func connected(unifiedNfcApi: UnifiedNfcApi) {
debugLog("normal nih.")
unifiedNfcApi.checkCard()
}
func complete(emoney: Emoney) {
DispatchQueue.main.async {
self.balanceText = Self.idrCurrencyFormatter.string(for: emoney.getBalance()) ?? "Rp 0"
self.cardTypeText = emoney.getCardLabel()
self.rawCardNumber = emoney.getCardNumber()
self.latestCardNumber = emoney.getCardNumber()
self.updateCardNumberDisplay()
if (emoney.isTampilRiwayat()){
let riwayat = emoney.getRiwayatList()
if let first = riwayat.first {
let dateFmt = DateFormatter()
dateFmt.dateFormat = "dd MMM yyyy, HH:mm"
let dateStr = first.getTransationTime().map { dateFmt.string(from: $0) } ?? ""
let isCredit = first.getProsesTipe() == 0
let sign = isCredit ? "+" : "-"
let place = first.getLocationName()?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let isKMT = emoney.getCardLabel().uppercased().contains("KMT")
self.lastTransaction = LastTransactionItem(
place: place,
date: dateStr,
amount: "\(sign)\(Self.idrCurrencyFormatter.string(for: first.getAmount()) ?? "Rp 0")",
status: isCredit ? L10n.topup : L10n.payment,
isCredit: isCredit,
isKMT: isKMT
)
}
// Simpan list untuk history
self.latestRiwayatList = riwayat
}
}
}
func failed(error: NSError) {}
}
private extension HomeViewController {
static let idrCurrencyFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.locale = Locale(identifier: "id_ID")
formatter.numberStyle = .currency
formatter.currencyCode = "IDR"
formatter.currencySymbol = "Rp "
formatter.maximumFractionDigits = 0
formatter.minimumFractionDigits = 0
return formatter
}()
}
// MARK: - UILabel letter spacing helper
private extension UILabel {
func letterSpacing(_ spacing: CGFloat) {
guard let text = text else { return }
let attributed = NSMutableAttributedString(string: text)
attributed.addAttribute(.kern, value: spacing, range: NSRange(location: 0, length: text.count))
attributedText = attributed
}
}
// MARK: - String card masking
private extension String {
/// Replaces the first 12 digits with * and shows the last 4, formatted as **** **** **** XXXX
func maskFirst12() -> String {
let digits = self.filter { $0.isNumber }
guard digits.count == 16 else { return self }
let last4 = String(digits.suffix(4))
return "**** **** **** \(last4)"
}
}