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,442 @@
import UIKit
import GoogleMobileAds
// MARK: - SettingsViewController
final class SettingsViewController: UIViewController {
// MARK: - Callbacks
var onLanguageTapped: (() -> Void)?
var onNotificationsTapped: (() -> Void)?
var onHelpCenterTapped: (() -> Void)?
var onAboutTapped: (() -> Void)?
var onShowCardNumberChanged: ((Bool) -> Void)?
// MARK: - State
private var showCardNumber: Bool = {
// First launch: key doesn't exist default ON and persist it
if UserDefaults.standard.object(forKey: "masked") == nil {
UserDefaults.standard.set(true, forKey: "masked")
return true
}
return UserDefaults.standard.bool(forKey: "masked")
}() {
didSet { onShowCardNumberChanged?(showCardNumber) }
}
// MARK: - UI
private let scrollView = UIScrollView()
private let contentView = UIView()
// Ad banner
private let adContainer = UIView()
private var bannerView = GADBannerView()
// Dynamic constraints toggled on ad load/fail
private var generalTopNoAd: NSLayoutConstraint!
private var generalTopWithAd: NSLayoutConstraint!
// Stored anchors for chaining
private var headerBottomAnchor: NSLayoutYAxisAnchor!
private var lastBottomAnchor: NSLayoutYAxisAnchor!
// TODO: Replace with real ad unit ID before release
private let adUnitID = "ca-app-pub-3389368171983845/7916200416"
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Theme.Color.background
setupScrollView()
setupHeader()
setupAdBanner()
setupGeneralSection()
setupAppSection()
setupFooter()
}
// MARK: - ScrollView
private func setupScrollView() {
scrollView.showsVerticalScrollIndicator = false
scrollView.translatesAutoresizingMaskIntoConstraints = false
contentView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scrollView)
scrollView.addSubview(contentView)
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor)
])
}
// MARK: - Header (no search button)
private func setupHeader() {
let titleLabel = UILabel()
titleLabel.text = L10n.settingsTitle
titleLabel.font = Theme.Font.title(weight: .bold)
titleLabel.textColor = Theme.Color.textPrimary
titleLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(titleLabel)
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20),
titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24)
])
headerBottomAnchor = titleLabel.bottomAnchor
lastBottomAnchor = titleLabel.bottomAnchor
}
// MARK: - Ad Banner
private func setupAdBanner() {
let adSize = GADCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(
UIScreen.main.bounds.width - 48
)
bannerView = GADBannerView(adSize: adSize)
bannerView.adUnitID = adUnitID
bannerView.rootViewController = self
bannerView.delegate = self
bannerView.translatesAutoresizingMaskIntoConstraints = false
adContainer.backgroundColor = .clear
adContainer.clipsToBounds = true
adContainer.isHidden = true
adContainer.translatesAutoresizingMaskIntoConstraints = false
adContainer.addSubview(bannerView)
contentView.addSubview(adContainer)
NSLayoutConstraint.activate([
adContainer.topAnchor.constraint(equalTo: headerBottomAnchor, constant: 16),
adContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
adContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24),
bannerView.topAnchor.constraint(equalTo: adContainer.topAnchor),
bannerView.leadingAnchor.constraint(equalTo: adContainer.leadingAnchor),
bannerView.trailingAnchor.constraint(equalTo: adContainer.trailingAnchor),
bannerView.bottomAnchor.constraint(equalTo: adContainer.bottomAnchor),
bannerView.heightAnchor.constraint(equalToConstant: CGFloat(adSize.size.height))
])
bannerView.load(GADRequest())
lastBottomAnchor = adContainer.bottomAnchor
}
// MARK: - General Section
private func setupGeneralSection() {
let sectionLabel = makeSectionLabel(L10n.sectionGeneral)
contentView.addSubview(sectionLabel)
// Two top constraints only one active at a time
generalTopNoAd = sectionLabel.topAnchor.constraint(equalTo: headerBottomAnchor, constant: 28)
generalTopWithAd = sectionLabel.topAnchor.constraint(equalTo: adContainer.bottomAnchor, constant: 20)
generalTopNoAd.isActive = true // default: no ad
NSLayoutConstraint.activate([
sectionLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24)
])
let card = makeCard()
contentView.addSubview(card)
NSLayoutConstraint.activate([
card.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: 10),
card.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
card.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24)
])
let languageRow = SettingsRow(
icon: "globe", title: L10n.languageTitle,
detail: L10n.languageValue, accessory: .chevron
)
languageRow.onTap = { [weak self] in self?.onLanguageTapped?() }
let toggleRow = SettingsRow(
icon: "eye", title: L10n.showCardNumberTitle,
subtitle: L10n.showCardNumberDesc, accessory: .toggle(isOn: showCardNumber)
)
toggleRow.onToggleChanged = { [weak self] isOn in self?.showCardNumber = isOn }
let stack = UIStackView(arrangedSubviews: [languageRow, makeSeparator(), toggleRow])
stack.axis = .vertical
stack.translatesAutoresizingMaskIntoConstraints = false
card.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 4),
stack.leadingAnchor.constraint(equalTo: card.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: card.trailingAnchor),
stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -4)
])
lastBottomAnchor = card.bottomAnchor
}
// MARK: - App Section
private func setupAppSection() {
let sectionLabel = makeSectionLabel(L10n.sectionApp)
contentView.addSubview(sectionLabel)
NSLayoutConstraint.activate([
sectionLabel.topAnchor.constraint(equalTo: lastBottomAnchor, constant: 24),
sectionLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24)
])
let card = makeCard()
contentView.addSubview(card)
NSLayoutConstraint.activate([
card.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: 10),
card.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
card.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24)
])
let helpRow = SettingsRow(
icon: "questionmark.circle", title: L10n.helpCenterTitle,
subtitle: L10n.helpCenterDesc, accessory: .chevron
)
helpRow.onTap = { [weak self] in self?.onHelpCenterTapped?() }
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
let versionSubtitle = "\(L10n.aboutAppDesc) \(appVersion) (Build \(buildNumber))"
let aboutRow = SettingsRow(
icon: "info.circle", title: L10n.aboutAppTitle,
subtitle: versionSubtitle, accessory: .chevron
)
aboutRow.onTap = { [weak self] in self?.onAboutTapped?() }
let stack = UIStackView(arrangedSubviews: [helpRow, makeSeparator(), aboutRow])
stack.axis = .vertical
stack.translatesAutoresizingMaskIntoConstraints = false
card.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 4),
stack.leadingAnchor.constraint(equalTo: card.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: card.trailingAnchor),
stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -4)
])
lastBottomAnchor = card.bottomAnchor
}
// MARK: - Footer
private func setupFooter() {
let appLabel = UILabel()
appLabel.text = L10n.footerCopyright
appLabel.font = Theme.Font.caption(weight: .semibold)
appLabel.textColor = Theme.Color.textSecondary
appLabel.textAlignment = .center
let stack = UIStackView(arrangedSubviews: [appLabel])
stack.axis = .vertical
stack.spacing = 4
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: lastBottomAnchor, constant: 32),
stack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -140)
])
}
// MARK: - Helpers
private func makeSectionLabel(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = Theme.Font.caption(weight: .semibold)
label.textColor = Theme.Color.textSecondary
label.translatesAutoresizingMaskIntoConstraints = false
return label
}
private func makeCard() -> UIView {
let card = UIView()
card.backgroundColor = Theme.Color.card
card.layer.cornerRadius = 16
card.layer.shadowColor = UIColor.black.cgColor
card.layer.shadowOpacity = 0.06
card.layer.shadowOffset = CGSize(width: 0, height: 2)
card.layer.shadowRadius = 8
card.translatesAutoresizingMaskIntoConstraints = false
return card
}
private func makeSeparator() -> UIView {
let line = UIView()
line.backgroundColor = Theme.Color.background
line.translatesAutoresizingMaskIntoConstraints = false
line.heightAnchor.constraint(equalToConstant: 1).isActive = true
return line
}
}
// MARK: - GADBannerViewDelegate
extension SettingsViewController: GADBannerViewDelegate {
func bannerViewDidReceiveAd(_ bannerView: GADBannerView) {
generalTopNoAd.isActive = false
generalTopWithAd.isActive = true
UIView.animate(withDuration: 0.3) {
self.adContainer.isHidden = false
self.view.layoutIfNeeded()
}
}
func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) {
generalTopWithAd.isActive = false
generalTopNoAd.isActive = true
UIView.animate(withDuration: 0.3) {
self.adContainer.isHidden = true
self.view.layoutIfNeeded()
}
}
}
// MARK: - SettingsRow
private enum SettingsAccessory {
case chevron
case toggle(isOn: Bool)
case detail(String)
}
private final class SettingsRow: UIView {
var onTap: (() -> Void)?
var onToggleChanged: ((Bool) -> Void)?
init(icon: String,
title: String,
subtitle: String? = nil,
detail: String? = nil,
accessory: SettingsAccessory) {
super.init(frame: .zero)
// Icon grey background, green icon (same as HistoryView)
let iconContainer = UIView()
iconContainer.backgroundColor = Theme.Color.background
iconContainer.layer.cornerRadius = 10
let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)
let iconView = UIImageView(image: UIImage(systemName: icon, withConfiguration: config))
iconView.tintColor = Theme.Color.secondary
iconView.contentMode = .scaleAspectFit
iconView.translatesAutoresizingMaskIntoConstraints = false
iconContainer.addSubview(iconView)
// Labels
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = Theme.Font.body(weight: .medium)
titleLabel.textColor = Theme.Color.textPrimary
titleLabel.numberOfLines = 0
let subtitleLabel = UILabel()
subtitleLabel.text = subtitle
subtitleLabel.font = Theme.Font.caption(weight: .regular)
subtitleLabel.textColor = Theme.Color.textSecondary
subtitleLabel.isHidden = subtitle == nil
subtitleLabel.numberOfLines = 0
// Right accessory
let rightView: UIView
switch accessory {
case .chevron:
let chevronConfig = UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
let img = UIImageView(image: UIImage(systemName: "chevron.right", withConfiguration: chevronConfig))
img.tintColor = Theme.Color.textSecondary
img.contentMode = .scaleAspectFit
rightView = img
case .toggle(let isOn):
let sw = UISwitch()
sw.isOn = isOn
sw.onTintColor = Theme.Color.primary
sw.addTarget(self, action: #selector(toggleChanged(_:)), for: .valueChanged)
rightView = sw
case .detail(let text):
let lbl = UILabel()
lbl.text = text
lbl.font = Theme.Font.body(weight: .regular)
lbl.textColor = Theme.Color.textSecondary
rightView = lbl
}
let labelStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
labelStack.axis = .vertical
labelStack.spacing = 2
let hStack = UIStackView(arrangedSubviews: [iconContainer, labelStack, rightView])
hStack.axis = .horizontal
hStack.spacing = 14
hStack.alignment = .center
hStack.translatesAutoresizingMaskIntoConstraints = false
addSubview(hStack)
NSLayoutConstraint.activate([
iconContainer.widthAnchor.constraint(equalToConstant: 36),
iconContainer.heightAnchor.constraint(equalToConstant: 36),
iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
iconView.widthAnchor.constraint(equalToConstant: 18),
iconView.heightAnchor.constraint(equalToConstant: 18),
hStack.topAnchor.constraint(equalTo: topAnchor, constant: 14),
hStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
hStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
hStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -14)
])
// labelStack selalu menyusut agar rightView tidak terdorong keluar
labelStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
labelStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
rightView.setContentHuggingPriority(.required, for: .horizontal)
rightView.setContentCompressionResistancePriority(.required, for: .horizontal)
if case .chevron = accessory {
let tap = UITapGestureRecognizer(target: self, action: #selector(rowTapped))
addGestureRecognizer(tap)
isUserInteractionEnabled = true
}
if let detail = detail, case .chevron = accessory {
let detailLabel = UILabel()
detailLabel.text = detail
detailLabel.font = Theme.Font.body(weight: .regular)
detailLabel.textColor = Theme.Color.textSecondary
detailLabel.setContentHuggingPriority(.required, for: .horizontal)
detailLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
hStack.removeArrangedSubview(rightView)
rightView.removeFromSuperview()
hStack.addArrangedSubview(detailLabel)
let chevronConfig = UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
let img = UIImageView(image: UIImage(systemName: "chevron.right", withConfiguration: chevronConfig))
img.tintColor = Theme.Color.textSecondary
img.contentMode = .scaleAspectFit
hStack.addArrangedSubview(img)
}
}
required init?(coder: NSCoder) { nil }
@objc private func rowTapped() { onTap?() }
@objc private func toggleChanged(_ sender: UISwitch) { onToggleChanged?(sender.isOn) }
}