509 lines
20 KiB
Swift
509 lines
20 KiB
Swift
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()
|
|
private var bannerRetryWorkItem: DispatchWorkItem?
|
|
private var isBannerAdLoaded = false
|
|
private var isBannerLoadInFlight = false
|
|
private let bannerRetryDelay: TimeInterval = 20
|
|
|
|
// 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()
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
loadBannerAdIfNeeded(reason: "viewDidAppear")
|
|
}
|
|
|
|
deinit {
|
|
bannerRetryWorkItem?.cancel()
|
|
}
|
|
|
|
// 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))
|
|
])
|
|
|
|
lastBottomAnchor = adContainer.bottomAnchor
|
|
}
|
|
|
|
private func loadBannerAdIfNeeded(reason: String) {
|
|
guard !isBannerAdLoaded, !isBannerLoadInFlight else { return }
|
|
guard isViewLoaded, view.window != nil else {
|
|
logAdMob("skip load: view is not visible", reason: reason)
|
|
return
|
|
}
|
|
|
|
bannerRetryWorkItem?.cancel()
|
|
bannerView.rootViewController = self
|
|
isBannerLoadInFlight = true
|
|
logAdMob("loading banner", reason: reason)
|
|
bannerView.load(GADRequest())
|
|
}
|
|
|
|
private func scheduleBannerRetry(after delay: TimeInterval = 20) {
|
|
bannerRetryWorkItem?.cancel()
|
|
|
|
let workItem = DispatchWorkItem { [weak self] in
|
|
self?.loadBannerAdIfNeeded(reason: "retry")
|
|
}
|
|
bannerRetryWorkItem = workItem
|
|
|
|
logAdMob("scheduling retry in \(Int(delay))s")
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
|
|
}
|
|
|
|
private func logAdMob(_ message: String, reason: String? = nil, error: Error? = nil) {
|
|
var parts = ["[AdMob][Settings]", message]
|
|
if let reason {
|
|
parts.append("reason=\(reason)")
|
|
}
|
|
parts.append("adUnitID=\(bannerView.adUnitID ?? "-")")
|
|
parts.append("visible=\(viewIfLoaded?.window != nil)")
|
|
parts.append("loaded=\(isBannerAdLoaded)")
|
|
parts.append("inFlight=\(isBannerLoadInFlight)")
|
|
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: " | "))
|
|
}
|
|
|
|
// 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) {
|
|
bannerRetryWorkItem?.cancel()
|
|
isBannerLoadInFlight = false
|
|
isBannerAdLoaded = true
|
|
logAdMob("banner loaded")
|
|
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) {
|
|
isBannerLoadInFlight = false
|
|
isBannerAdLoaded = false
|
|
logAdMob("banner failed", error: error)
|
|
generalTopWithAd.isActive = false
|
|
generalTopNoAd.isActive = true
|
|
UIView.animate(withDuration: 0.3) {
|
|
self.adContainer.isHidden = true
|
|
self.view.layoutIfNeeded()
|
|
}
|
|
scheduleBannerRetry(after: bannerRetryDelay)
|
|
}
|
|
}
|
|
|
|
// 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) }
|
|
}
|