Files
Emoney-Info---IOS/Emoney Info/HomeView.swift

734 lines
29 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = 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)"
}
}