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 { if let date = riwayat.getTransationTime() { let fmt = DateFormatter() fmt.dateFormat = "MMM d, yyyy · HH:mm" return fmt.string(from: date) } if let raw = riwayat.getDesk(), !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return raw } return "–" } 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 ────────────────────────────────────────── let normalizedCardLabel = cardLabel.lowercased() let hideLocationForPdf = normalizedCardLabel.contains("flazz") || normalizedCardLabel.contains("mandiri e-money") let hasLocation = !hideLocationForPdf && 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) } ?? rw.getDesk() ?? "–" 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