Initial commit
This commit is contained in:
442
Emoney Info/SettingsView.swift
Normal file
442
Emoney Info/SettingsView.swift
Normal 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) }
|
||||
}
|
||||
Reference in New Issue
Block a user