797 lines
32 KiB
Swift
797 lines
32 KiB
Swift
import SwiftUI
|
||
import GoogleMobileAds
|
||
|
||
// MARK: - HistoryView
|
||
|
||
struct HistoryView: View {
|
||
|
||
let transactions: [RiwayatCard]
|
||
var onBack: (() -> Void)?
|
||
var onExportPDF: (([RiwayatCard]) -> Void)?
|
||
|
||
@State private var selectedFilter: FilterOption = .allTime
|
||
@State private var adLoaded: Bool = false
|
||
|
||
// TODO: Replace with real ad unit ID before release
|
||
private let adUnitID = "ca-app-pub-3389368171983845/5404549734"
|
||
|
||
// Pre-compute banner size so the UIView always gets a valid height
|
||
private let bannerAdSize: GADAdSize = GADCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(
|
||
UIScreen.main.bounds.width - 48
|
||
)
|
||
private var bannerAdHeight: CGFloat { CGFloat(bannerAdSize.size.height) }
|
||
|
||
enum FilterOption: CaseIterable {
|
||
case allTime, today, thisWeek, thisMonth
|
||
var label: String {
|
||
switch self {
|
||
case .allTime: return L10n.filterAllTime
|
||
case .today: return L10n.filterToday
|
||
case .thisWeek: return L10n.filterThisWeek
|
||
case .thisMonth: return L10n.filterThisMonth
|
||
}
|
||
}
|
||
}
|
||
|
||
private var filtered: [RiwayatCard] {
|
||
guard selectedFilter != .allTime else { return transactions }
|
||
var calendar = Calendar.current
|
||
calendar.firstWeekday = 2 // Senin sebagai awal minggu
|
||
let now = Date()
|
||
return transactions.filter { rw in
|
||
guard let date = rw.getTransationTime() else { return false }
|
||
switch selectedFilter {
|
||
case .today:
|
||
return calendar.isDateInToday(date)
|
||
case .thisWeek:
|
||
guard let interval = calendar.dateInterval(of: .weekOfYear, for: now) else { return false }
|
||
return interval.contains(date)
|
||
case .thisMonth:
|
||
guard let interval = calendar.dateInterval(of: .month, for: now) else { return false }
|
||
return interval.contains(date)
|
||
case .allTime:
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .bottom) {
|
||
Color(Theme.Color.background).ignoresSafeArea()
|
||
|
||
ScrollView(showsIndicators: false) {
|
||
LazyVStack(spacing: 0) {
|
||
headerView
|
||
.padding(.horizontal, 24)
|
||
.padding(.top, 16)
|
||
|
||
// Ad banner — inner frame always has proper height so Google can load it;
|
||
// outer frame collapses to 0 + clipped to hide it until loaded.
|
||
BannerAdView(
|
||
adUnitID: adUnitID,
|
||
adSize: bannerAdSize,
|
||
onAdLoaded: { adLoaded = true },
|
||
onAdFailed: { adLoaded = false }
|
||
)
|
||
.frame(height: bannerAdHeight) // proper height — always valid for GAD
|
||
.clipped()
|
||
.frame(height: adLoaded ? bannerAdHeight : 0) // collapse space when hidden
|
||
.padding(.horizontal, 24)
|
||
.padding(.top, adLoaded ? 16 : 0)
|
||
.opacity(adLoaded ? 1 : 0)
|
||
.animation(.easeInOut(duration: 0.3), value: adLoaded)
|
||
|
||
sectionHeader
|
||
.padding(.horizontal, 24)
|
||
.padding(.top, adLoaded ? 20 : 28)
|
||
.padding(.bottom, 12)
|
||
.animation(.easeInOut(duration: 0.3), value: adLoaded)
|
||
|
||
transactionList
|
||
|
||
Spacer().frame(height: 100)
|
||
}
|
||
}
|
||
|
||
exportButton
|
||
.padding(.horizontal, 24)
|
||
.padding(.bottom, 90)
|
||
}
|
||
}
|
||
|
||
// MARK: - Header
|
||
|
||
private var headerView: some View {
|
||
HStack {
|
||
Button(action: { onBack?() }) {
|
||
Image(systemName: "arrow.left")
|
||
.font(.system(size: 18, weight: .semibold))
|
||
.foregroundColor(Color(Theme.Color.textPrimary))
|
||
.frame(width: 36, height: 36)
|
||
.background(Color(Theme.Color.card))
|
||
.clipShape(Circle())
|
||
.shadow(color: .black.opacity(0.06), radius: 6, x: 0, y: 2)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
Text(L10n.historyTitle)
|
||
.font(.system(size: Theme.FontSize.subtitle, weight: .bold))
|
||
.foregroundColor(Color(Theme.Color.textPrimary))
|
||
|
||
Spacer()
|
||
|
||
// Placeholder to balance the HStack (same width as back button)
|
||
Color.clear.frame(width: 36, height: 36)
|
||
}
|
||
}
|
||
|
||
// MARK: - Section Header
|
||
|
||
private var sectionHeader: some View {
|
||
HStack {
|
||
Text(L10n.recentActivity)
|
||
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
|
||
.foregroundColor(Color(Theme.Color.textSecondary))
|
||
.kerning(1.2)
|
||
|
||
Spacer()
|
||
|
||
Menu {
|
||
ForEach(FilterOption.allCases, id: \.self) { option in
|
||
Button(option.label) { selectedFilter = option }
|
||
}
|
||
} label: {
|
||
HStack(spacing: 4) {
|
||
Text(selectedFilter.label)
|
||
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
|
||
Image(systemName: "chevron.down")
|
||
.font(.system(size: 10, weight: .semibold))
|
||
}
|
||
.foregroundColor(Color(Theme.Color.secondary))
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 6)
|
||
.background(Color(Theme.Color.primary).opacity(0.15))
|
||
.clipShape(Capsule())
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Transaction List
|
||
|
||
private var transactionList: some View {
|
||
LazyVStack(spacing: 10) {
|
||
if filtered.isEmpty {
|
||
emptyState
|
||
} else {
|
||
ForEach(Array(filtered.enumerated()), id: \.offset) { _, rw in
|
||
TransactionRow(riwayat: rw)
|
||
.padding(.horizontal, 24)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var emptyState: some View {
|
||
VStack(spacing: 12) {
|
||
Image(systemName: "tray")
|
||
.font(.system(size: 40))
|
||
.foregroundColor(Color(Theme.Color.textSecondary))
|
||
Text(L10n.noTransactionsFound)
|
||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||
.foregroundColor(Color(Theme.Color.textSecondary))
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.top, 48)
|
||
}
|
||
|
||
// MARK: - Export Button
|
||
|
||
private var exportButton: some View {
|
||
let hasData = !filtered.isEmpty
|
||
return Button(action: { onExportPDF?(filtered) }) {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "arrow.down.doc.fill")
|
||
.font(.system(size: 15, weight: .semibold))
|
||
Text(L10n.exportPDF)
|
||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||
}
|
||
.foregroundColor(hasData ? Color(Theme.Color.secondary) : Color(Theme.Color.textSecondary))
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 52)
|
||
.background(Color(Theme.Color.card))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 14)
|
||
.stroke(hasData ? Color(Theme.Color.primary) : Color(Theme.Color.textSecondary).opacity(0.3), lineWidth: 1.5)
|
||
)
|
||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||
.shadow(color: .black.opacity(hasData ? 0.06 : 0), radius: 8, x: 0, y: 2)
|
||
}
|
||
.disabled(!hasData)
|
||
.animation(.easeInOut(duration: 0.2), value: hasData)
|
||
}
|
||
}
|
||
|
||
// MARK: - BannerAdView (UIViewRepresentable)
|
||
|
||
private struct BannerAdView: UIViewRepresentable {
|
||
|
||
let adUnitID: String
|
||
let adSize: GADAdSize
|
||
var onAdLoaded: (() -> Void)?
|
||
var onAdFailed: (() -> Void)?
|
||
|
||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||
|
||
func makeUIView(context: Context) -> HistoryBannerView {
|
||
let banner = HistoryBannerView(adSize: adSize)
|
||
banner.adUnitID = adUnitID
|
||
banner.delegate = context.coordinator
|
||
banner.onDidMoveToWindow = { [weak coordinator = context.coordinator] bannerView in
|
||
coordinator?.handleBannerDidMoveToWindow(bannerView)
|
||
}
|
||
return banner
|
||
}
|
||
|
||
func updateUIView(_ uiView: HistoryBannerView, context: Context) {
|
||
context.coordinator.attachRootViewControllerIfNeeded(to: uiView)
|
||
context.coordinator.loadBannerIfNeeded(uiView, reason: "updateUIView")
|
||
}
|
||
|
||
// MARK: Coordinator
|
||
|
||
final class Coordinator: NSObject, GADBannerViewDelegate {
|
||
let parent: BannerAdView
|
||
private var retryWorkItem: DispatchWorkItem?
|
||
private var isAdLoaded = false
|
||
private var isLoadInFlight = false
|
||
private let retryDelay: TimeInterval = 20
|
||
|
||
init(_ parent: BannerAdView) { self.parent = parent }
|
||
|
||
deinit {
|
||
retryWorkItem?.cancel()
|
||
}
|
||
|
||
func bannerViewDidReceiveAd(_ bannerView: GADBannerView) {
|
||
retryWorkItem?.cancel()
|
||
isLoadInFlight = false
|
||
isAdLoaded = true
|
||
log("banner loaded", bannerView: bannerView)
|
||
DispatchQueue.main.async { self.parent.onAdLoaded?() }
|
||
}
|
||
|
||
func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) {
|
||
isLoadInFlight = false
|
||
isAdLoaded = false
|
||
log("banner failed", bannerView: bannerView, error: error)
|
||
scheduleRetry(for: bannerView)
|
||
DispatchQueue.main.async { self.parent.onAdFailed?() }
|
||
}
|
||
|
||
func handleBannerDidMoveToWindow(_ bannerView: GADBannerView) {
|
||
attachRootViewControllerIfNeeded(to: bannerView)
|
||
loadBannerIfNeeded(bannerView, reason: "didMoveToWindow")
|
||
}
|
||
|
||
func attachRootViewControllerIfNeeded(to bannerView: GADBannerView) {
|
||
guard let rootViewController = findNearestViewController(from: bannerView) else {
|
||
log("root view controller not ready", bannerView: bannerView)
|
||
return
|
||
}
|
||
|
||
if bannerView.rootViewController !== rootViewController {
|
||
bannerView.rootViewController = rootViewController
|
||
log("attached root view controller", bannerView: bannerView)
|
||
}
|
||
}
|
||
|
||
func loadBannerIfNeeded(_ bannerView: GADBannerView, reason: String) {
|
||
guard !isAdLoaded, !isLoadInFlight else { return }
|
||
guard bannerView.window != nil else {
|
||
log("skip load: banner is not in window", bannerView: bannerView, reason: reason)
|
||
return
|
||
}
|
||
guard bannerView.rootViewController != nil else {
|
||
log("skip load: root view controller missing", bannerView: bannerView, reason: reason)
|
||
return
|
||
}
|
||
|
||
retryWorkItem?.cancel()
|
||
isLoadInFlight = true
|
||
log("loading banner", bannerView: bannerView, reason: reason)
|
||
bannerView.load(GADRequest())
|
||
}
|
||
|
||
private func scheduleRetry(for bannerView: GADBannerView) {
|
||
retryWorkItem?.cancel()
|
||
|
||
let workItem = DispatchWorkItem { [weak self, weak bannerView] in
|
||
guard let self, let bannerView else { return }
|
||
self.attachRootViewControllerIfNeeded(to: bannerView)
|
||
self.loadBannerIfNeeded(bannerView, reason: "retry")
|
||
}
|
||
retryWorkItem = workItem
|
||
|
||
log("scheduling retry in \(Int(retryDelay))s", bannerView: bannerView)
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay, execute: workItem)
|
||
}
|
||
|
||
private func log(_ message: String, bannerView: GADBannerView, reason: String? = nil, error: Error? = nil) {
|
||
var parts = ["[AdMob][History]", message]
|
||
if let reason {
|
||
parts.append("reason=\(reason)")
|
||
}
|
||
parts.append("adUnitID=\(bannerView.adUnitID ?? "-")")
|
||
parts.append("visible=\(bannerView.window != nil)")
|
||
parts.append("loaded=\(isAdLoaded)")
|
||
parts.append("inFlight=\(isLoadInFlight)")
|
||
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 findNearestViewController(from view: UIView) -> UIViewController? {
|
||
sequence(first: view.next, next: { $0?.next })
|
||
.first { $0 is UIViewController } as? UIViewController
|
||
}
|
||
}
|
||
}
|
||
|
||
private final class HistoryBannerView: GADBannerView {
|
||
var onDidMoveToWindow: ((HistoryBannerView) -> Void)?
|
||
|
||
override func didMoveToWindow() {
|
||
super.didMoveToWindow()
|
||
onDidMoveToWindow?(self)
|
||
}
|
||
}
|
||
|
||
// MARK: - TransactionRow
|
||
|
||
private struct TransactionRow: View {
|
||
|
||
let riwayat: RiwayatCard
|
||
|
||
private var isCredit: Bool { riwayat.getProsesTipe() == 0 }
|
||
|
||
private var place: String {
|
||
riwayat.getLocationName()?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
}
|
||
|
||
private var hasPlace: Bool { !place.isEmpty }
|
||
|
||
private var title: String {
|
||
hasPlace ? place : (isCredit ? L10n.topup : L10n.payment)
|
||
}
|
||
|
||
private var amount: String {
|
||
let formatter = NumberFormatter()
|
||
formatter.locale = Locale(identifier: "id_ID")
|
||
formatter.numberStyle = .currency
|
||
formatter.currencyCode = "IDR"
|
||
formatter.currencySymbol = "Rp "
|
||
formatter.minimumFractionDigits = 0
|
||
formatter.maximumFractionDigits = 0
|
||
let value = riwayat.getAmount()
|
||
let formatted = formatter.string(for: value) ?? "Rp \(value)"
|
||
return isCredit ? "+\(formatted)" : "-\(formatted)"
|
||
}
|
||
|
||
private var dateText: String {
|
||
guard let date = riwayat.getTransationTime() else { return "–" }
|
||
let fmt = DateFormatter()
|
||
fmt.dateFormat = "MMM d, yyyy · HH:mm"
|
||
return fmt.string(from: date)
|
||
}
|
||
|
||
private var statusText: String { isCredit ? L10n.topup : L10n.payment }
|
||
|
||
private var iconName: String {
|
||
let t = title.lowercased()
|
||
if t.contains("top") || t.contains("deposit") || t.contains("bank") { return "arrow.down.circle.fill" }
|
||
if t.contains("coffee") || t.contains("cafe") { return "cup.and.saucer.fill" }
|
||
if t.contains("transit") || t.contains("metro") || t.contains("mrt") || t.contains("krl") { return "tram.fill" }
|
||
if t.contains("cloud") || t.contains("storage") { return "cloud.fill" }
|
||
if t.contains("bistro") || t.contains("resto") || t.contains("food") { return "fork.knife" }
|
||
if t.contains("market") || t.contains("grocery") { return "cart.fill" }
|
||
return isCredit ? "arrow.down.circle.fill" : "creditcard.fill"
|
||
}
|
||
|
||
private var amountColor: Color {
|
||
isCredit ? Color(Theme.Color.success) : Color(UIColor.systemRed)
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: 14) {
|
||
// Icon — grey background, green icon
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.fill(Color(Theme.Color.background))
|
||
.frame(width: 44, height: 44)
|
||
Image(systemName: iconName)
|
||
.font(.system(size: 18, weight: .medium))
|
||
.foregroundColor(Color(Theme.Color.secondary))
|
||
}
|
||
|
||
// Title + Date
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(title)
|
||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||
.foregroundColor(Color(Theme.Color.textPrimary))
|
||
.lineLimit(2)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
Text(dateText)
|
||
.font(.system(size: Theme.FontSize.caption))
|
||
.foregroundColor(Color(Theme.Color.textSecondary))
|
||
}
|
||
|
||
Spacer()
|
||
|
||
// Amount + Status badge (only when place is available)
|
||
VStack(alignment: .trailing, spacing: 4) {
|
||
Text(amount)
|
||
.font(.system(size: Theme.FontSize.body, weight: .bold))
|
||
.foregroundColor(amountColor)
|
||
.lineLimit(1)
|
||
|
||
if hasPlace {
|
||
let statusColor = isCredit ? Color(Theme.Color.success) : Color(UIColor.systemOrange)
|
||
Text(isCredit ? L10n.topup : L10n.payment)
|
||
.font(.system(size: 9, weight: .bold))
|
||
.foregroundColor(statusColor)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 3)
|
||
.background(statusColor.opacity(0.12))
|
||
.clipShape(Capsule())
|
||
}
|
||
}
|
||
}
|
||
.padding(16)
|
||
.background(Color(Theme.Color.card))
|
||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||
.shadow(color: .black.opacity(0.05), radius: 6, x: 0, y: 2)
|
||
}
|
||
}
|
||
|
||
// MARK: - UIKit Bridge
|
||
|
||
import UIKit
|
||
|
||
final class HistoryHostingController: UIViewController {
|
||
|
||
var riwayatList: [RiwayatCard] = []
|
||
var cardLabel: String = ""
|
||
var balanceText: String = ""
|
||
var cardNumber: String = ""
|
||
|
||
// MARK: - Interstitial Ad
|
||
|
||
private let interstitialAdUnitID = "ca-app-pub-3389368171983845/1759422963"
|
||
private var interstitial: GADInterstitialAd?
|
||
private var pendingExportList: [RiwayatCard] = []
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
|
||
var historyView = HistoryView(transactions: riwayatList)
|
||
historyView.onBack = { [weak self] in self?.dismiss(animated: true) }
|
||
historyView.onExportPDF = { [weak self] filteredList in self?.handleExportPDFTapped(filteredList) }
|
||
|
||
let hosting = UIHostingController(rootView: historyView)
|
||
addChild(hosting)
|
||
hosting.view.frame = view.bounds
|
||
hosting.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||
view.addSubview(hosting.view)
|
||
hosting.didMove(toParent: self)
|
||
|
||
loadInterstitial()
|
||
}
|
||
|
||
private func loadInterstitial() {
|
||
debugLog("[AdMob][HistoryInterstitial] loading interstitial | adUnitID=\(interstitialAdUnitID)")
|
||
GADInterstitialAd.load(
|
||
withAdUnitID: interstitialAdUnitID,
|
||
request: GADRequest()
|
||
) { [weak self] ad, error in
|
||
if let error = error as NSError? {
|
||
debugLog(
|
||
"[AdMob][HistoryInterstitial] failed | adUnitID=\(self?.interstitialAdUnitID ?? "-") | errorDomain=\(error.domain) | errorCode=\(error.code) | error=\(error.localizedDescription)"
|
||
)
|
||
} else {
|
||
debugLog("[AdMob][HistoryInterstitial] loaded | adUnitID=\(self?.interstitialAdUnitID ?? "-")")
|
||
}
|
||
|
||
self?.interstitial = ad
|
||
self?.interstitial?.fullScreenContentDelegate = self
|
||
}
|
||
}
|
||
|
||
// MARK: - Export trigger
|
||
|
||
private func handleExportPDFTapped(_ filteredList: [RiwayatCard]) {
|
||
pendingExportList = filteredList
|
||
if let interstitial {
|
||
interstitial.present(fromRootViewController: self)
|
||
} else {
|
||
exportPDF()
|
||
}
|
||
}
|
||
|
||
// MARK: - PDF Export
|
||
|
||
private func exportPDF() {
|
||
let data = buildPDF(from: pendingExportList)
|
||
let fileName = "emoney_history_\(Date().timeIntervalSince1970).pdf"
|
||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
|
||
do {
|
||
try data.write(to: url)
|
||
} catch {
|
||
return
|
||
}
|
||
let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil)
|
||
activityVC.popoverPresentationController?.sourceView = view
|
||
present(activityVC, animated: true)
|
||
}
|
||
|
||
private func buildPDF(from list: [RiwayatCard]) -> Data {
|
||
let pageW: CGFloat = 595.2
|
||
let pageH: CGFloat = 841.8
|
||
let margin: CGFloat = 40
|
||
let colW = pageW - margin * 2
|
||
|
||
let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: pageW, height: pageH))
|
||
|
||
return renderer.pdfData { ctx in
|
||
ctx.beginPage()
|
||
var y: CGFloat = margin
|
||
|
||
// ── Formatters ────────────────────────────────────────────
|
||
let dateFmt = DateFormatter()
|
||
dateFmt.locale = Locale(identifier: "id_ID")
|
||
dateFmt.dateFormat = "dd MMMM yyyy, HH:mm"
|
||
|
||
let numFmt = NumberFormatter()
|
||
numFmt.locale = Locale(identifier: "id_ID")
|
||
numFmt.numberStyle = .currency
|
||
numFmt.currencySymbol = "Rp "
|
||
numFmt.groupingSeparator = "."
|
||
numFmt.decimalSeparator = ","
|
||
numFmt.maximumFractionDigits = 0
|
||
|
||
// ── Styles ────────────────────────────────────────────────
|
||
let bodyFont = UIFont.systemFont(ofSize: 10)
|
||
let boldFont = UIFont.boldSystemFont(ofSize: 10)
|
||
let titleFont = UIFont.boldSystemFont(ofSize: 13)
|
||
let headerFont = UIFont.boldSystemFont(ofSize: 10)
|
||
let smallFont = UIFont.systemFont(ofSize: 9)
|
||
let green = UIColor(red: 0.36, green: 0.49, blue: 0.48, alpha: 1)
|
||
|
||
func attrs(_ font: UIFont,
|
||
_ color: UIColor = .black,
|
||
align: NSTextAlignment = .left) -> [NSAttributedString.Key: Any] {
|
||
let para = NSMutableParagraphStyle()
|
||
para.alignment = align
|
||
return [.font: font, .foregroundColor: color, .paragraphStyle: para]
|
||
}
|
||
|
||
func drawText(_ text: String, x: CGFloat, y: CGFloat,
|
||
width: CGFloat, font: UIFont,
|
||
color: UIColor = .black,
|
||
align: NSTextAlignment = .left) {
|
||
let rect = CGRect(x: x, y: y, width: width, height: 200)
|
||
(text as NSString).draw(in: rect, withAttributes: attrs(font, color, align: align))
|
||
}
|
||
|
||
func textHeight(_ text: String, font: UIFont, width: CGFloat) -> CGFloat {
|
||
let rect = CGRect(x: 0, y: 0, width: width, height: .greatestFiniteMagnitude)
|
||
let bounding = (text as NSString).boundingRect(
|
||
with: rect.size,
|
||
options: [.usesLineFragmentOrigin, .usesFontLeading],
|
||
attributes: [.font: font], context: nil)
|
||
return ceil(bounding.height)
|
||
}
|
||
|
||
// ── Header image ───────────────────────────────────────────
|
||
if let headerImg = UIImage(named: "header") {
|
||
let imgH: CGFloat = 30
|
||
let imgW = headerImg.size.width * (imgH / headerImg.size.height)
|
||
let imgRect = CGRect(x: margin, y: y, width: imgW, height: imgH)
|
||
headerImg.draw(in: imgRect)
|
||
y += imgH + 12
|
||
} else {
|
||
// Fallback: draw green square with "E"
|
||
let iconSize: CGFloat = 48
|
||
green.setFill()
|
||
UIBezierPath(roundedRect: CGRect(x: margin, y: y,
|
||
width: iconSize, height: iconSize),
|
||
cornerRadius: 10).fill()
|
||
drawText("E", x: margin, y: y + 14, width: iconSize,
|
||
font: titleFont, color: .white, align: .center)
|
||
y += iconSize + 12
|
||
}
|
||
|
||
// ── Subtitle ──────────────────────────────────────────────
|
||
let subtitle = "Dibuat oleh aplikasi emoney Info: cek saldo dan riwayat uang elektronik."
|
||
drawText(subtitle, x: margin, y: y, width: colW, font: bodyFont)
|
||
y += textHeight(subtitle, font: bodyFont, width: colW) + 16
|
||
|
||
// ── Separator line ────────────────────────────────────────
|
||
UIColor.lightGray.setStroke()
|
||
let linePath = UIBezierPath()
|
||
linePath.move(to: CGPoint(x: margin, y: y))
|
||
linePath.addLine(to: CGPoint(x: pageW - margin, y: y))
|
||
linePath.lineWidth = 0.5
|
||
linePath.stroke()
|
||
y += 10
|
||
|
||
// ── Card Info (left-aligned, 3 fixed columns) ─────────────
|
||
// Label | : | Value
|
||
let labelW: CGFloat = 90
|
||
let colonX: CGFloat = margin + labelW
|
||
let valueX: CGFloat = colonX + 14
|
||
|
||
func drawInfoRow(label: String, value: String) {
|
||
drawText(label, x: margin, y: y, width: labelW, font: boldFont)
|
||
drawText(":", x: colonX, y: y, width: 14, font: bodyFont)
|
||
drawText(value, x: valueX, y: y, width: colW - labelW - 14, font: bodyFont)
|
||
}
|
||
|
||
drawInfoRow(label: "Kartu", value: cardLabel)
|
||
y += 16
|
||
drawInfoRow(label: "Saldo", value: balanceText)
|
||
y += 16
|
||
drawInfoRow(label: "Nomor Kartu", value: cardNumber.formatCardNumber())
|
||
y += 24
|
||
|
||
// ── Separator ─────────────────────────────────────────────
|
||
UIColor.lightGray.setStroke()
|
||
let line2 = UIBezierPath()
|
||
line2.move(to: CGPoint(x: margin, y: y))
|
||
line2.addLine(to: CGPoint(x: pageW - margin, y: y))
|
||
line2.lineWidth = 0.5
|
||
line2.stroke()
|
||
y += 10
|
||
|
||
// ── Table Header ──────────────────────────────────────────
|
||
// Cek apakah ada data lokasi di seluruh list
|
||
let hasLocation = list.contains {
|
||
let loc = $0.getLocationName()?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
return !loc.isEmpty
|
||
}
|
||
|
||
let dateColW: CGFloat = 130
|
||
let typeColW: CGFloat = 70
|
||
let locColW: CGFloat = hasLocation ? 120 : 0
|
||
let amtColX = margin + dateColW + typeColW + locColW
|
||
|
||
drawText("Tanggal", x: margin, y: y, width: dateColW, font: headerFont, color: green)
|
||
drawText("Transaksi", x: margin + dateColW, y: y, width: typeColW, font: headerFont, color: green)
|
||
if hasLocation {
|
||
drawText("Lokasi", x: margin + dateColW + typeColW, y: y, width: locColW, font: headerFont, color: green)
|
||
}
|
||
drawText("Jumlah", x: amtColX, y: y,
|
||
width: colW - dateColW - typeColW - locColW, font: headerFont, color: green, align: .right)
|
||
y += 14
|
||
|
||
// Header underline
|
||
green.withAlphaComponent(0.4).setStroke()
|
||
let headerLine = UIBezierPath()
|
||
headerLine.move(to: CGPoint(x: margin, y: y))
|
||
headerLine.addLine(to: CGPoint(x: pageW - margin, y: y))
|
||
headerLine.lineWidth = 0.5
|
||
headerLine.stroke()
|
||
y += 6
|
||
|
||
// ── Table Rows ────────────────────────────────────────────
|
||
for (i, rw) in list.enumerated() {
|
||
let location = rw.getLocationName()?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
let rowHeight: CGFloat = 16
|
||
|
||
// New page if near bottom
|
||
if y > pageH - margin - rowHeight {
|
||
ctx.beginPage()
|
||
y = margin
|
||
}
|
||
|
||
// Alternating row background
|
||
if i % 2 == 0 {
|
||
UIColor(white: 0.97, alpha: 1).setFill()
|
||
UIBezierPath(rect: CGRect(x: margin - 4, y: y - 2,
|
||
width: colW + 8, height: rowHeight)).fill()
|
||
}
|
||
|
||
let dateStr = rw.getTransationTime().map { dateFmt.string(from: $0) } ?? "–"
|
||
let typeStr = rw.getProsesTipe() == 0 ? "Top up" : "Payment"
|
||
let amtStr = numFmt.string(for: rw.getAmount()) ?? "Rp 0"
|
||
let amtColor: UIColor = rw.getProsesTipe() == 0
|
||
? UIColor(red: 0.13, green: 0.55, blue: 0.13, alpha: 1)
|
||
: .black
|
||
|
||
drawText(dateStr, x: margin, y: y, width: dateColW, font: smallFont)
|
||
drawText(typeStr, x: margin + dateColW, y: y, width: typeColW, font: smallFont)
|
||
if hasLocation {
|
||
drawText(location.isEmpty ? "–" : location,
|
||
x: margin + dateColW + typeColW, y: y, width: locColW, font: smallFont)
|
||
}
|
||
drawText(amtStr, x: amtColX, y: y,
|
||
width: colW - dateColW - typeColW - locColW, font: smallFont,
|
||
color: amtColor, align: .right)
|
||
y += rowHeight
|
||
}
|
||
|
||
// ── Footer ────────────────────────────────────────────────
|
||
y += 10
|
||
UIColor.lightGray.setStroke()
|
||
let footerLine = UIBezierPath()
|
||
footerLine.move(to: CGPoint(x: margin, y: y))
|
||
footerLine.addLine(to: CGPoint(x: pageW - margin, y: y))
|
||
footerLine.lineWidth = 0.5
|
||
footerLine.stroke()
|
||
y += 6
|
||
drawText("emoneyInfo © \(Calendar.current.component(.year, from: Date()))",
|
||
x: margin, y: y, width: colW, font: smallFont,
|
||
color: .lightGray, align: .center)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - GADFullScreenContentDelegate
|
||
|
||
extension HistoryHostingController: GADFullScreenContentDelegate {
|
||
|
||
// Called when the interstitial is dismissed — proceed with export
|
||
func adDidDismissFullScreenContent(_ ad: GADFullScreenPresentingAd) {
|
||
debugLog("[AdMob][HistoryInterstitial] dismissed")
|
||
interstitial = nil
|
||
loadInterstitial() // pre-load for the next export attempt
|
||
exportPDF()
|
||
}
|
||
|
||
// Called when the interstitial fails to present — fall back to direct export
|
||
func ad(_ ad: GADFullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) {
|
||
let nsError = error as NSError
|
||
debugLog(
|
||
"[AdMob][HistoryInterstitial] present failed | errorDomain=\(nsError.domain) | errorCode=\(nsError.code) | error=\(nsError.localizedDescription)"
|
||
)
|
||
interstitial = nil
|
||
exportPDF()
|
||
}
|
||
}
|
||
|
||
// MARK: - Preview
|
||
|
||
#if DEBUG
|
||
struct HistoryView_Previews: PreviewProvider {
|
||
static var previews: some View {
|
||
let items: [RiwayatCard] = {
|
||
let data: [(String, Int, Int, String)] = [
|
||
("Whole Foods Market", 142500, 1, "2023-10-19 08:43:00"),
|
||
("Blue Bottle Coffee", 6500, 1, "2023-10-19 09:15:00"),
|
||
("Metropolitan Transit", 2750, 1, "2023-10-20 07:12:00"),
|
||
("Deposit from Bank", 500000, 0, "2023-10-21 11:30:00"),
|
||
("Cloud Storage Pro", 9990, 1, "2023-10-20 09:00:00"),
|
||
("The GreenBistro", 34200, 1, "2023-10-19 20:15:00"),
|
||
]
|
||
let fmt = DateFormatter()
|
||
fmt.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||
return data.map { (title, amount, tipe, dateStr) in
|
||
let rw = RiwayatCard()
|
||
rw.setTitle(title)
|
||
rw.setAmount(amount)
|
||
rw.setProsesTipe(tipe)
|
||
if let date = fmt.date(from: dateStr) { rw.setTransactionTime(date) }
|
||
return rw
|
||
}
|
||
}()
|
||
HistoryView(transactions: items)
|
||
}
|
||
}
|
||
#endif
|