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

797 lines
32 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 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