Files
Emoney-Info---IOS/Emoney Info/FAQViewController.swift
Wira Basalamah 8f0b001501 Initial commit
2026-04-24 04:55:24 +07:00

557 lines
23 KiB
Swift

// FAQViewController.swift
// Emoney Info
//
// Halaman Pusat Bantuan / FAQ
// - Search bar berfungsi (filter pertanyaan real-time)
// - Filter chip per kategori berfungsi
// - Accordion: tap pertanyaan untuk expand/collapse jawaban
import UIKit
final class FAQViewController: UIViewController {
// MARK: - State
private var allCategories: [FAQCategory] = FAQData.all
private var activeFilter: String? = nil // nil = semua kategori
private var searchText: String = ""
private var expandedItems: Set<String> = [] // key = "catID|qIndex"
// Computed: filtered categories based on search + filter chip
private var displayedCategories: [FAQCategory] {
var source = allCategories
// 1. Filter by category chip
if let filter = activeFilter {
source = source.filter { $0.id == filter }
}
// 2. Filter by search text
if !searchText.isEmpty {
let q = searchText.lowercased()
source = source.compactMap { cat in
let filtered = cat.items.filter {
$0.question.lowercased().contains(q) || $0.answer.lowercased().contains(q)
}
if filtered.isEmpty { return nil }
return FAQCategory(id: cat.id, icon: cat.icon, title: cat.title, items: filtered)
}
}
return source
}
// MARK: - UI
private let scrollView = UIScrollView()
private let contentView = UIView()
private let headerView = UIView()
private let backButton = UIButton(type: .system)
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let searchBar = UISearchBar()
// Category filter chips (horizontal scroll)
private let chipScrollView = UIScrollView()
private let chipStack = UIStackView()
// FAQ accordion container
private let faqContainerStack = UIStackView()
// "Still need help?" footer card
private let helpCard = UIView()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Theme.Color.background
setupScrollView()
setupHeader()
setupChips()
setupFAQSection()
setupHelpCard()
reloadFAQ()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
(tabBarController as? MainTabBarController)?.setTabBarHidden(true, animated: animated)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
(tabBarController as? MainTabBarController)?.setTabBarHidden(false, animated: animated)
}
// MARK: - Setup: ScrollView
private func setupScrollView() {
scrollView.showsVerticalScrollIndicator = false
scrollView.keyboardDismissMode = .onDrag
scrollView.translatesAutoresizingMaskIntoConstraints = false
contentView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scrollView)
scrollView.addSubview(contentView)
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.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: - Setup: Header
private func setupHeader() {
// Teal rounded-bottom header card
headerView.backgroundColor = Theme.Color.primary
headerView.layer.cornerRadius = 24
headerView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
headerView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(headerView)
// Back button
let chevron = UIImage(systemName: "chevron.left",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold))
backButton.setImage(chevron, for: .normal)
backButton.tintColor = .white
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
backButton.translatesAutoresizingMaskIntoConstraints = false
// Header nav label "Help Center"
let navLabel = UILabel()
navLabel.text = L10n.helpCenterTitle
navLabel.font = Theme.Font.caption(weight: .semibold)
navLabel.textColor = UIColor.white.withAlphaComponent(0.85)
navLabel.translatesAutoresizingMaskIntoConstraints = false
// Title
titleLabel.text = L10n.faqHeaderTitle
titleLabel.font = Theme.Font.title(weight: .bold)
titleLabel.textColor = .white
titleLabel.numberOfLines = 0
titleLabel.translatesAutoresizingMaskIntoConstraints = false
// Search bar
searchBar.placeholder = L10n.faqSearchPlaceholder
searchBar.searchBarStyle = .minimal
searchBar.backgroundColor = .white
searchBar.layer.cornerRadius = 12
searchBar.clipsToBounds = true
searchBar.delegate = self
searchBar.translatesAutoresizingMaskIntoConstraints = false
if let tf = searchBar.value(forKey: "searchField") as? UITextField {
tf.backgroundColor = .white
tf.font = Theme.Font.body(weight: .regular)
}
[backButton, navLabel, titleLabel, searchBar].forEach { headerView.addSubview($0) }
NSLayoutConstraint.activate([
headerView.topAnchor.constraint(equalTo: contentView.topAnchor),
headerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
headerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
backButton.topAnchor.constraint(equalTo: headerView.safeAreaLayoutGuide.topAnchor, constant: 16),
backButton.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 20),
backButton.widthAnchor.constraint(equalToConstant: 32),
backButton.heightAnchor.constraint(equalToConstant: 32),
navLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
navLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 8),
titleLabel.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 20),
titleLabel.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 24),
titleLabel.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -24),
searchBar.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16),
searchBar.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 16),
searchBar.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -16),
searchBar.bottomAnchor.constraint(equalTo: headerView.bottomAnchor, constant: -20),
searchBar.heightAnchor.constraint(equalToConstant: 44),
])
}
// MARK: - Setup: Category Chips
private func setupChips() {
chipScrollView.showsHorizontalScrollIndicator = false
chipScrollView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(chipScrollView)
chipStack.axis = .horizontal
chipStack.spacing = 10
chipStack.alignment = .center
chipStack.translatesAutoresizingMaskIntoConstraints = false
chipScrollView.addSubview(chipStack)
NSLayoutConstraint.activate([
chipScrollView.topAnchor.constraint(equalTo: headerView.bottomAnchor, constant: 20),
chipScrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
chipScrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
chipScrollView.heightAnchor.constraint(equalToConstant: 44),
chipStack.centerYAnchor.constraint(equalTo: chipScrollView.centerYAnchor),
chipStack.leadingAnchor.constraint(equalTo: chipScrollView.leadingAnchor, constant: 20),
chipStack.trailingAnchor.constraint(equalTo: chipScrollView.trailingAnchor, constant: -20),
])
// "All" chip + one per category
buildChips()
}
private func buildChips() {
chipStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
let allChip = makeChip(id: nil, icon: "square.grid.2x2", title: L10n.faqFilterAll)
chipStack.addArrangedSubview(allChip)
for cat in allCategories {
let chip = makeChip(id: cat.id, icon: cat.icon, title: cat.title)
chipStack.addArrangedSubview(chip)
}
}
private func makeChip(id: String?, icon: String, title: String) -> UIView {
let isActive = (id == activeFilter)
let container = UIControl()
container.backgroundColor = isActive ? Theme.Color.primary : .white
container.layer.cornerRadius = 18
container.layer.borderWidth = isActive ? 0 : 1
container.layer.borderColor = UIColor.systemGray5.cgColor
container.layer.shadowColor = UIColor.black.cgColor
container.layer.shadowOpacity = isActive ? 0.08 : 0.04
container.layer.shadowOffset = CGSize(width: 0, height: 2)
container.layer.shadowRadius = 4
let iconView = UIImageView(image: UIImage(systemName: icon,
withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium)))
iconView.tintColor = isActive ? .white : Theme.Color.secondary
iconView.contentMode = .scaleAspectFit
iconView.translatesAutoresizingMaskIntoConstraints = false
let label = UILabel()
label.text = title
label.font = Theme.Font.caption(weight: .semibold)
label.textColor = isActive ? .white : Theme.Color.textPrimary
label.translatesAutoresizingMaskIntoConstraints = false
let stack = UIStackView(arrangedSubviews: [iconView, label])
stack.axis = .horizontal
stack.spacing = 5
stack.alignment = .center
stack.isUserInteractionEnabled = false
stack.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(stack)
NSLayoutConstraint.activate([
iconView.widthAnchor.constraint(equalToConstant: 14),
iconView.heightAnchor.constraint(equalToConstant: 14),
stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 14),
stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -14),
stack.centerYAnchor.constraint(equalTo: container.centerYAnchor),
container.heightAnchor.constraint(equalToConstant: 36),
])
// Store filter ID in accessibilityIdentifier for lookup
container.accessibilityIdentifier = id ?? "__all__"
container.addTarget(self, action: #selector(chipTapped(_:)), for: .touchUpInside)
return container
}
// MARK: - Setup: FAQ Accordion Section
private func setupFAQSection() {
faqContainerStack.axis = .vertical
faqContainerStack.spacing = 12
faqContainerStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(faqContainerStack)
NSLayoutConstraint.activate([
faqContainerStack.topAnchor.constraint(equalTo: chipScrollView.bottomAnchor, constant: 20),
faqContainerStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
faqContainerStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
])
}
// MARK: - Setup: Help Card Footer
private func setupHelpCard() {
helpCard.backgroundColor = Theme.Color.primary
helpCard.layer.cornerRadius = 20
helpCard.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(helpCard)
let iconView = UIImageView(image: UIImage(systemName: "questionmark.bubble.fill",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 28, weight: .medium)))
iconView.tintColor = .white
iconView.translatesAutoresizingMaskIntoConstraints = false
let helpTitle = UILabel()
helpTitle.text = L10n.faqHelpCardTitle
helpTitle.font = Theme.Font.subtitle(weight: .bold)
helpTitle.textColor = .white
helpTitle.numberOfLines = 0
helpTitle.translatesAutoresizingMaskIntoConstraints = false
let helpDesc = UILabel()
helpDesc.text = L10n.faqHelpCardDesc
helpDesc.font = Theme.Font.caption(weight: .regular)
helpDesc.textColor = UIColor.white.withAlphaComponent(0.85)
helpDesc.numberOfLines = 0
helpDesc.translatesAutoresizingMaskIntoConstraints = false
let emailButton = UIButton(type: .system)
emailButton.setTitle(L10n.faqEmailSupport, for: .normal)
emailButton.setImage(UIImage(systemName: "envelope.fill",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)), for: .normal)
emailButton.tintColor = Theme.Color.primary
emailButton.backgroundColor = .white
emailButton.layer.cornerRadius = 14
emailButton.titleLabel?.font = Theme.Font.body(weight: .semibold)
emailButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 20, bottom: 12, right: 20)
emailButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: -6, bottom: 0, right: 0)
emailButton.addTarget(self, action: #selector(emailTapped), for: .touchUpInside)
emailButton.translatesAutoresizingMaskIntoConstraints = false
[iconView, helpTitle, helpDesc, emailButton].forEach { helpCard.addSubview($0) }
NSLayoutConstraint.activate([
helpCard.topAnchor.constraint(equalTo: faqContainerStack.bottomAnchor, constant: 28),
helpCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
helpCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
helpCard.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -140),
iconView.topAnchor.constraint(equalTo: helpCard.topAnchor, constant: 24),
iconView.leadingAnchor.constraint(equalTo: helpCard.leadingAnchor, constant: 24),
iconView.widthAnchor.constraint(equalToConstant: 36),
iconView.heightAnchor.constraint(equalToConstant: 36),
helpTitle.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 12),
helpTitle.leadingAnchor.constraint(equalTo: helpCard.leadingAnchor, constant: 24),
helpTitle.trailingAnchor.constraint(equalTo: helpCard.trailingAnchor, constant: -24),
helpDesc.topAnchor.constraint(equalTo: helpTitle.bottomAnchor, constant: 8),
helpDesc.leadingAnchor.constraint(equalTo: helpCard.leadingAnchor, constant: 24),
helpDesc.trailingAnchor.constraint(equalTo: helpCard.trailingAnchor, constant: -24),
emailButton.topAnchor.constraint(equalTo: helpDesc.bottomAnchor, constant: 20),
emailButton.leadingAnchor.constraint(equalTo: helpCard.leadingAnchor, constant: 24),
emailButton.bottomAnchor.constraint(equalTo: helpCard.bottomAnchor, constant: -24),
])
}
// MARK: - Reload FAQ Accordion
private func reloadFAQ() {
faqContainerStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
let categories = displayedCategories
if categories.isEmpty {
let emptyLabel = UILabel()
emptyLabel.text = L10n.faqNoResults
emptyLabel.font = Theme.Font.body(weight: .regular)
emptyLabel.textColor = Theme.Color.textSecondary
emptyLabel.textAlignment = .center
emptyLabel.numberOfLines = 0
faqContainerStack.addArrangedSubview(emptyLabel)
return
}
for cat in categories {
// Category header label
let catLabel = UILabel()
catLabel.text = cat.title.uppercased()
catLabel.font = Theme.Font.caption(weight: .semibold)
catLabel.textColor = Theme.Color.textSecondary
faqContainerStack.addArrangedSubview(catLabel)
faqContainerStack.setCustomSpacing(8, after: catLabel)
// White card holding all items for this category
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
let itemStack = UIStackView()
itemStack.axis = .vertical
itemStack.spacing = 0
itemStack.translatesAutoresizingMaskIntoConstraints = false
card.addSubview(itemStack)
NSLayoutConstraint.activate([
itemStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 4),
itemStack.leadingAnchor.constraint(equalTo: card.leadingAnchor),
itemStack.trailingAnchor.constraint(equalTo: card.trailingAnchor),
itemStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -4),
])
for (qIndex, item) in cat.items.enumerated() {
let key = "\(cat.id)|\(qIndex)"
let isExpanded = expandedItems.contains(key)
let row = FAQItemRow(
item: item,
isExpanded: isExpanded,
onToggle: { [weak self] in
self?.toggleItem(key: key)
}
)
itemStack.addArrangedSubview(row)
if qIndex < cat.items.count - 1 {
let sep = UIView()
sep.backgroundColor = Theme.Color.background
sep.translatesAutoresizingMaskIntoConstraints = false
sep.heightAnchor.constraint(equalToConstant: 1).isActive = true
itemStack.addArrangedSubview(sep)
}
}
faqContainerStack.addArrangedSubview(card)
faqContainerStack.setCustomSpacing(20, after: card)
}
}
// MARK: - Toggle accordion item
private func toggleItem(key: String) {
if expandedItems.contains(key) {
expandedItems.remove(key)
} else {
expandedItems.insert(key)
}
UIView.animate(withDuration: 0.25, delay: 0,
usingSpringWithDamping: 0.85, initialSpringVelocity: 0) {
self.reloadFAQ()
self.view.layoutIfNeeded()
}
}
// MARK: - Actions
@objc private func backTapped() {
navigationController?.popViewController(animated: true)
}
@objc private func chipTapped(_ sender: UIControl) {
let rawID = sender.accessibilityIdentifier
activeFilter = (rawID == "__all__") ? nil : rawID
expandedItems.removeAll()
buildChips()
reloadFAQ()
}
@objc private func emailTapped() {
let address = "apps@indonesiainyourhand.com"
let subject = "Ask Support"
let encoded = subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? subject
if let url = URL(string: "mailto:\(address)?subject=\(encoded)") {
UIApplication.shared.open(url)
}
}
}
// MARK: - UISearchBarDelegate
extension FAQViewController: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
self.searchText = searchText
expandedItems.removeAll()
reloadFAQ()
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
searchBar.resignFirstResponder()
}
}
// MARK: - FAQItemRow (Accordion Row)
private final class FAQItemRow: UIView {
private let questionLabel = UILabel()
private let answerLabel = UILabel()
private let chevron = UIImageView()
var onToggle: (() -> Void)?
init(item: FAQItem, isExpanded: Bool, onToggle: @escaping () -> Void) {
self.onToggle = onToggle
super.init(frame: .zero)
setup(item: item, isExpanded: isExpanded)
}
required init?(coder: NSCoder) { fatalError() }
private func setup(item: FAQItem, isExpanded: Bool) {
// Chevron icon
let chevronConfig = UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
chevron.image = UIImage(systemName: "chevron.down", withConfiguration: chevronConfig)
chevron.tintColor = Theme.Color.textSecondary
chevron.contentMode = .scaleAspectFit
chevron.transform = isExpanded
? CGAffineTransform(rotationAngle: .pi)
: .identity
chevron.translatesAutoresizingMaskIntoConstraints = false
// Question
questionLabel.text = item.question
questionLabel.font = Theme.Font.body(weight: .medium)
questionLabel.textColor = Theme.Color.textPrimary
questionLabel.numberOfLines = 0
// Answer
answerLabel.text = item.answer
answerLabel.font = Theme.Font.caption(weight: .regular)
answerLabel.textColor = Theme.Color.textSecondary
answerLabel.numberOfLines = 0
answerLabel.isHidden = !isExpanded
let questionStack = UIStackView(arrangedSubviews: [questionLabel, chevron])
questionStack.axis = .horizontal
questionStack.spacing = 12
questionStack.alignment = .top
questionStack.translatesAutoresizingMaskIntoConstraints = false
let mainStack = UIStackView(arrangedSubviews: [questionStack, answerLabel])
mainStack.axis = .vertical
mainStack.spacing = 10
mainStack.translatesAutoresizingMaskIntoConstraints = false
addSubview(mainStack)
NSLayoutConstraint.activate([
chevron.widthAnchor.constraint(equalToConstant: 16),
chevron.heightAnchor.constraint(equalToConstant: 16),
mainStack.topAnchor.constraint(equalTo: topAnchor, constant: 16),
mainStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
mainStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
mainStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16),
])
let tap = UITapGestureRecognizer(target: self, action: #selector(rowTapped))
addGestureRecognizer(tap)
isUserInteractionEnabled = true
}
@objc private func rowTapped() { onToggle?() }
}