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) } }