Initial commit

This commit is contained in:
Wira Basalamah
2026-04-24 04:55:24 +07:00
commit 8f0b001501
128 changed files with 9366 additions and 0 deletions

View 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