Initial commit
This commit is contained in:
688
Emoney Info/HistoryView.swift
Normal file
688
Emoney Info/HistoryView.swift
Normal file
@ -0,0 +1,688 @@
|
||||
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) -> GADBannerView {
|
||||
let banner = GADBannerView(adSize: adSize)
|
||||
banner.adUnitID = adUnitID
|
||||
banner.delegate = context.coordinator
|
||||
banner.rootViewController = context.coordinator.findRootViewController()
|
||||
banner.load(GADRequest())
|
||||
return banner
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: GADBannerView, context: Context) {}
|
||||
|
||||
// MARK: Coordinator
|
||||
|
||||
final class Coordinator: NSObject, GADBannerViewDelegate {
|
||||
let parent: BannerAdView
|
||||
init(_ parent: BannerAdView) { self.parent = parent }
|
||||
|
||||
func bannerViewDidReceiveAd(_ bannerView: GADBannerView) {
|
||||
DispatchQueue.main.async { self.parent.onAdLoaded?() }
|
||||
}
|
||||
|
||||
func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) {
|
||||
DispatchQueue.main.async { self.parent.onAdFailed?() }
|
||||
}
|
||||
|
||||
func findRootViewController() -> UIViewController? {
|
||||
UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap { $0.windows }
|
||||
.first { $0.isKeyWindow }?
|
||||
.rootViewController
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
GADInterstitialAd.load(
|
||||
withAdUnitID: interstitialAdUnitID,
|
||||
request: GADRequest()
|
||||
) { [weak self] ad, _ in
|
||||
// Store ad if loaded; ignore error — fallback to direct export
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
Reference in New Issue
Block a user