734 lines
29 KiB
Swift
734 lines
29 KiB
Swift
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)"
|
||
}
|
||
}
|