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() private var bannerRetryWorkItem: DispatchWorkItem? private var isBannerAdLoaded = false private var isBannerLoadInFlight = false private let bannerRetryDelay: TimeInterval = 20 // 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 ) loadBannerAdIfNeeded(reason: "viewDidAppear") } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) NotificationCenter.default.removeObserver(self, name: Notification.Name("refreshScreen"), object: nil) } deinit { bannerRetryWorkItem?.cancel() } 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() { loadBannerAdIfNeeded(reason: "external") } private func loadBannerAdIfNeeded(reason: String) { guard !isBannerAdLoaded, !isBannerLoadInFlight else { return } guard isViewLoaded, view.window != nil else { logAdMob("skip load: view is not visible", reason: reason) return } bannerRetryWorkItem?.cancel() bannerView.rootViewController = self isBannerLoadInFlight = true logAdMob("loading banner", reason: reason) bannerView.load(GADRequest()) } private func scheduleBannerRetry(after delay: TimeInterval = 20) { bannerRetryWorkItem?.cancel() let workItem = DispatchWorkItem { [weak self] in self?.loadBannerAdIfNeeded(reason: "retry") } bannerRetryWorkItem = workItem logAdMob("scheduling retry in \(Int(delay))s") DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } private func logAdMob(_ message: String, reason: String? = nil, error: Error? = nil) { var parts = ["[AdMob][Home]", message] if let reason { parts.append("reason=\(reason)") } parts.append("adUnitID=\(bannerView.adUnitID ?? "-")") parts.append("visible=\(viewIfLoaded?.window != nil)") parts.append("loaded=\(isBannerAdLoaded)") parts.append("inFlight=\(isBannerLoadInFlight)") parts.append("rootVC=\(String(describing: type(of: bannerView.rootViewController)))") if let nsError = error as NSError? { parts.append("errorDomain=\(nsError.domain)") parts.append("errorCode=\(nsError.code)") parts.append("error=\(nsError.localizedDescription)") } debugLog(parts.joined(separator: " | ")) } 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) { bannerRetryWorkItem?.cancel() isBannerLoadInFlight = false isBannerAdLoaded = true logAdMob("banner loaded") 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) { isBannerLoadInFlight = false isBannerAdLoaded = false logAdMob("banner failed", error: error) lastTxTopWithAd.isActive = false lastTxTopNoAd.isActive = true UIView.animate(withDuration: 0.3) { self.promoCard.isHidden = true self.view.layoutIfNeeded() } scheduleBannerRetry(after: bannerRetryDelay) } } // 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 = self.deduplicateHistory(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) } ?? first.getDesk() ?? "–" 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 }() func deduplicateHistory(_ items: [RiwayatCard]) -> [RiwayatCard] { var seen = Set() return items.filter { item in let key = historyKey(for: item) return seen.insert(key).inserted } } func historyKey(for item: RiwayatCard) -> String { let timestamp = item.getTransationTime()?.timeIntervalSince1970 ?? 0 let locationId = item.getLocationId() ?? "" let locationName = item.getLocationName() ?? "" let title = item.getTitle() ?? "" return "\(timestamp)|\(item.getAmount())|\(item.getProsesTipe())|\(locationId)|\(locationName)|\(title)" } } // 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)" } }