diff --git a/Projects/Presentation/Resources/Images.xcassets/exclamation_filled_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/exclamation_filled_icon.imageset/Contents.json new file mode 100644 index 00000000..39073909 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/exclamation_filled_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "exclamation_filled_icon@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "exclamation_filled_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "exclamation_filled_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/exclamation_filled_icon.imageset/exclamation_filled_icon@1x.png b/Projects/Presentation/Resources/Images.xcassets/exclamation_filled_icon.imageset/exclamation_filled_icon@1x.png new file mode 100644 index 00000000..f203f6ea Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/exclamation_filled_icon.imageset/exclamation_filled_icon@1x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/exclamation_filled_icon.imageset/exclamation_filled_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/exclamation_filled_icon.imageset/exclamation_filled_icon@2x.png new file mode 100644 index 00000000..9ced4aaf Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/exclamation_filled_icon.imageset/exclamation_filled_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/exclamation_filled_icon.imageset/exclamation_filled_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/exclamation_filled_icon.imageset/exclamation_filled_icon@3x.png new file mode 100644 index 00000000..3f6f7e72 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/exclamation_filled_icon.imageset/exclamation_filled_icon@3x.png differ diff --git a/Projects/Presentation/Sources/Common/Component/TableViewCell/BitnagilBaseTableViewCell.swift b/Projects/Presentation/Sources/Common/Component/TableViewCell/BitnagilBaseTableViewCell.swift new file mode 100644 index 00000000..95d96ab2 --- /dev/null +++ b/Projects/Presentation/Sources/Common/Component/TableViewCell/BitnagilBaseTableViewCell.swift @@ -0,0 +1,44 @@ +// +// MyPageTableViewCell.swift +// Presentation +// +// Created by 이동현 on 7/17/25. +// + +import SnapKit +import UIKit + +class BitnagilBaseTableViewCell: UITableViewCell { + private enum Layout { + static let titleLableLeadingSpacing: CGFloat = 20 + } + + private let titleLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String) { + titleLabel.text = title + } + + func configureAttribute() { + titleLabel.font = BitnagilFont(style: .body1, weight: .regular).font + } + + func configureLayout() { + contentView.addSubview(titleLabel) + + titleLabel.snp.makeConstraints { make in + make.verticalEdges.equalToSuperview() + make.leading.equalToSuperview().offset(Layout.titleLableLeadingSpacing) + } + } +} diff --git a/Projects/Presentation/Sources/Common/Component/TableViewCell/BitnagilButtonTableViewCell.swift b/Projects/Presentation/Sources/Common/Component/TableViewCell/BitnagilButtonTableViewCell.swift new file mode 100644 index 00000000..3c9c5440 --- /dev/null +++ b/Projects/Presentation/Sources/Common/Component/TableViewCell/BitnagilButtonTableViewCell.swift @@ -0,0 +1,87 @@ +// +// BitnagilButtonTableViewCell.swift +// Presentation +// +// Created by 이동현 on 7/30/25. +// + +import SnapKit +import UIKit + +protocol BitnagilButtonTableViewCellDelegate: AnyObject { + func bitnagilButtonTableViewCellDidTapButton(_ sender: BitnagilButtonTableViewCell) +} + +final class BitnagilButtonTableViewCell: BitnagilBaseTableViewCell { + private enum Layout { + static let buttonTrailingSpacing: CGFloat = 20 + static let buttonHeight: CGFloat = 30 + static let buttonPadding: CGFloat = 10 + static let buttonCornerRadius: CGFloat = 4 + } + + private let button = UIButton() + private var buttonWidthConstraint: Constraint? + weak var delegate: BitnagilButtonTableViewCellDelegate? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure( + title: String, + buttonTitle: String, + isButtonEnabled: Bool + ) { + super.configure(title: title) + + let font = BitnagilFont.init(style: .body2, weight: .semiBold).font + let textAttribute = [NSAttributedString.Key.font: font] + let textWidth = (buttonTitle as NSString).size(withAttributes: textAttribute).width + let buttonWidth = textWidth + Layout.buttonPadding * 2 + + buttonWidthConstraint?.update(offset: buttonWidth) + button.setTitle(buttonTitle, for: .normal) + button.isEnabled = isButtonEnabled + + if isButtonEnabled { + button.backgroundColor = BitnagilColor.lightBlue100 + button.setTitleColor(BitnagilColor.navy500, for: .normal) + } else { + button.backgroundColor = BitnagilColor.gray98 + button.setTitleColor(BitnagilColor.gray70, for: .disabled) + } + } + + override func configureAttribute() { + super.configureAttribute() + + button.titleLabel?.font = BitnagilFont.init(style: .body2, weight: .semiBold).font + button.layer.cornerRadius = Layout.buttonCornerRadius + button.addAction( + UIAction { [weak self] _ in + guard let self else { return } + self.delegate?.bitnagilButtonTableViewCellDidTapButton(self) + }, + for: .touchUpInside) + } + + override func configureLayout() { + super.configureLayout() + + contentView.addSubview(button) + + button.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset(Layout.buttonTrailingSpacing) + make.height.equalTo(Layout.buttonHeight) + buttonWidthConstraint = make.width + .equalTo(Layout.buttonPadding) + .constraint + } + } +} diff --git a/Projects/Presentation/Sources/MyPage/View/Component/MypageTableViewCell.swift b/Projects/Presentation/Sources/Common/Component/TableViewCell/BitnagilChevronTableViewCell.swift similarity index 53% rename from Projects/Presentation/Sources/MyPage/View/Component/MypageTableViewCell.swift rename to Projects/Presentation/Sources/Common/Component/TableViewCell/BitnagilChevronTableViewCell.swift index af7160b7..a24edbea 100644 --- a/Projects/Presentation/Sources/MyPage/View/Component/MypageTableViewCell.swift +++ b/Projects/Presentation/Sources/Common/Component/TableViewCell/BitnagilChevronTableViewCell.swift @@ -1,40 +1,31 @@ // -// MyPageTableViewCell.swift +// BitnagilChevronTableViewCell.swift // Presentation // -// Created by 이동현 on 7/17/25. +// Created by 이동현 on 7/30/25. // import SnapKit import UIKit -final class MypageTableViewCell: UITableViewCell { +final class BitnagilChevronTableViewCell: BitnagilBaseTableViewCell { private enum Layout { - static let titleLableLeadingSpacing: CGFloat = 20 - static let titleLableTrailingSpacing: CGFloat = 8 static let chevronImageViewTrailingSpacing: CGFloat = 16 static let chevronImageViewSize: CGFloat = 16 } - private let titleLabel = UILabel() private let chevronImageView = UIImageView() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) - configureAttribute() - configureLayout() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func configure(title: String) { - titleLabel.text = title - } - - private func configureAttribute() { - titleLabel.font = BitnagilFont(style: .body1, weight: .regular).font + override func configureAttribute() { + super.configureAttribute() chevronImageView.tintColor = .black chevronImageView.image = BitnagilIcon @@ -42,8 +33,9 @@ final class MypageTableViewCell: UITableViewCell { .withRenderingMode(.alwaysTemplate) } - private func configureLayout() { - contentView.addSubview(titleLabel) + override func configureLayout() { + super.configureLayout() + contentView.addSubview(chevronImageView) chevronImageView.snp.makeConstraints { make in @@ -51,11 +43,5 @@ final class MypageTableViewCell: UITableViewCell { make.trailing.equalToSuperview().inset(Layout.chevronImageViewTrailingSpacing) make.size.equalTo(Layout.chevronImageViewSize) } - - titleLabel.snp.makeConstraints { make in - make.verticalEdges.equalToSuperview() - make.leading.equalToSuperview().offset(Layout.titleLableLeadingSpacing) - make.trailing.equalTo(chevronImageView.snp.leading).offset(-Layout.titleLableTrailingSpacing) - } } } diff --git a/Projects/Presentation/Sources/Common/Component/TableViewCell/BitnagilToggleTableViewCell.swift b/Projects/Presentation/Sources/Common/Component/TableViewCell/BitnagilToggleTableViewCell.swift new file mode 100644 index 00000000..240d8f4e --- /dev/null +++ b/Projects/Presentation/Sources/Common/Component/TableViewCell/BitnagilToggleTableViewCell.swift @@ -0,0 +1,60 @@ +// +// BitnagilToggleTableViewCell.swift +// Presentation +// +// Created by 이동현 on 7/30/25. +// + +import SnapKit +import UIKit + +protocol BitnagilToggleTableViewCellDelegate: AnyObject { + func bitnagilToggleTableViewCellDidToggle(_ sender: BitnagilToggleTableViewCell, isOn: Bool) +} + +final class BitnagilToggleTableViewCell: BitnagilBaseTableViewCell { + private enum Layout { + static let switchTrailingSpacing: CGFloat = 20 + static let switchHeight: CGFloat = 24 + static let switchWidth: CGFloat = 44 + } + + private let toggleSwitch = UISwitch() + weak var delegate: BitnagilToggleTableViewCellDelegate? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func configureAttribute() { + super.configureAttribute() + + toggleSwitch.onTintColor = BitnagilColor.navy500 + toggleSwitch.tintColor = BitnagilColor.gray95 + toggleSwitch.addAction(UIAction { [weak self] action in + guard let self = self else { return } + self.delegate?.bitnagilToggleTableViewCellDidToggle(self, isOn: toggleSwitch.isOn) + }, for: .valueChanged) + } + + override func configureLayout() { + super.configureLayout() + + contentView.addSubview(toggleSwitch) + + toggleSwitch.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset(Layout.switchTrailingSpacing) + make.height.equalTo(Layout.switchHeight) + make.width.equalTo(Layout.switchWidth) + } + } + + func configureToggleState(isOn: Bool) { + toggleSwitch.isOn = isOn + } +} diff --git a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift index 088a9dfa..07664233 100644 --- a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift +++ b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift @@ -40,7 +40,7 @@ enum BitnagilIcon { // MARK: - Mypage Icons static let settingIcon = UIImage(named: "setting_icon", in: bundle, with: nil) - + static let exclamationFilledIcon = UIImage(named: "exclamation_filled_icon", in: bundle, with: nil) // MARK: - Routine Creation Icons static let asteriskIcon = UIImage(named: "asterisk_icon", in: bundle, with: nil) static let deleteIcon = UIImage(named: "delete_icon", in: bundle, with: nil) diff --git a/Projects/Presentation/Sources/Common/Extension/UIViewController+.swift b/Projects/Presentation/Sources/Common/Extension/UIViewController+.swift index 9e0d7229..37cb3975 100644 --- a/Projects/Presentation/Sources/Common/Extension/UIViewController+.swift +++ b/Projects/Presentation/Sources/Common/Extension/UIViewController+.swift @@ -10,6 +10,13 @@ import UIKit extension UIViewController { // MARK: - NavigationBar func configureNavigationBar(navigationStyle: NavigationBarStyle) { + let appearance = UINavigationBarAppearance() + appearance.backgroundEffect = .none + appearance.configureWithOpaqueBackground() + appearance.shadowColor = .clear + navigationController?.navigationBar.standardAppearance = appearance + navigationController?.navigationBar.scrollEdgeAppearance = appearance + switch navigationStyle { case .hidden: navigationController?.setNavigationBarHidden(true, animated: false) diff --git a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift index 605916d8..4f69ce3e 100644 --- a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift +++ b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift @@ -76,5 +76,9 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol { return ResultRecommendedRoutineViewModel(resultRecommendedRoutineUseCase: resultRecommendedRoutineUseCase) } + + DIContainer.shared.register(type: SettingViewModel.self) { _ in + return SettingViewModel() + } } } diff --git a/Projects/Presentation/Sources/Common/View/BitnagilAlert.swift b/Projects/Presentation/Sources/Common/View/BitnagilAlert.swift new file mode 100644 index 00000000..e844a239 --- /dev/null +++ b/Projects/Presentation/Sources/Common/View/BitnagilAlert.swift @@ -0,0 +1,179 @@ +// +// BitnagilAlert.swift +// Presentation +// +// Created by 이동현 on 8/2/25. +// + +import SnapKit +import UIKit + +final class BitnagilAlert: UIViewController { + enum AlertType { + case withImage + case plainText + } + + private enum Layout { + static let contentViewHorizontalSpacing: CGFloat = 39 + static let contentViewHorizontalHeight: CGFloat = 154 + static let imageSize: CGFloat = 55 + static let imageTopSpacing: CGFloat = 24 + static let titleTopSpacing: CGFloat = 18 + static let titleHeight: CGFloat = 30 + static let contentTopSpacing: CGFloat = 2 + static let contentHeight: CGFloat = 18 + static let buttonTopSpacing: CGFloat = 18 + static let buttonHorizontalSpacing: CGFloat = 20 + static let buttonHeight: CGFloat = 44 + static let confirmButtonLeadingSpacing: CGFloat = 8 + static let buttonBottomSpacing: CGFloat = 24 + } + + private let dimmedView = UIView() + private let contentView = UIView() + private let alertImageView = UIImageView() + private let titleLabel = UILabel() + private let contentLabel = UILabel() + private let cancelButton = UIButton() + private let confirmButton = UIButton() + private let alertType: AlertType + private var cancelHandler: (() -> Void)? + private var confirmHandler: (() -> Void)? + + init( + alertType: AlertType, + title: String, + content: String, + cancelButtonTitle: String, + confirmButtonTitle: String, + cancelHandler: (() -> Void)?, + confirmHandler: (() -> Void)? + ) { + self.alertType = alertType + super.init(nibName: nil, bundle: nil) + + self.cancelHandler = cancelHandler + self.confirmHandler = confirmHandler + titleLabel.text = title + contentLabel.text = content + cancelButton.setTitle(cancelButtonTitle, for: .normal) + confirmButton.setTitle(confirmButtonTitle, for: .normal) + + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + view.backgroundColor = .clear + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapDimmedView)) + dimmedView.addGestureRecognizer(tapGesture) + dimmedView.backgroundColor = .black + dimmedView.alpha = 0.7 + + contentView.backgroundColor = .white + contentView.layer.cornerRadius = 20 + contentView.layer.masksToBounds = true + + alertImageView.image = BitnagilIcon.exclamationFilledIcon + titleLabel.font = BitnagilFont(style: .title2, weight: .bold).font + contentLabel.font = BitnagilFont(style: .caption1, weight: .regular).font + + cancelButton.backgroundColor = .white + cancelButton.setTitleColor(BitnagilColor.navy500, for: .normal) + confirmButton.backgroundColor = BitnagilColor.navy500 + confirmButton.setTitleColor(.white, for: .normal) + [cancelButton, confirmButton].forEach { + $0.titleLabel?.font = BitnagilFont(style: .subtitle1, weight: .bold).font + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 8 + $0.layer.masksToBounds = true + } + + cancelButton.addAction( + UIAction { [weak self] _ in + self?.cancelHandler?() + self?.dismiss(animated: false) + }, + for: .touchUpInside) + + confirmButton.addAction( + UIAction { [weak self] _ in + self?.confirmHandler?() + self?.dismiss(animated: false) + }, + for: .touchUpInside) + } + + private func configureLayout() { + view.addSubview(dimmedView) + view.addSubview(contentView) + + contentView.addSubview(titleLabel) + contentView.addSubview(contentLabel) + contentView.addSubview(cancelButton) + contentView.addSubview(confirmButton) + + dimmedView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + contentView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.horizontalEdges.equalToSuperview().inset(Layout.contentViewHorizontalSpacing) + make.height.equalTo(Layout.contentViewHorizontalHeight).priority(.medium) + } + + if alertType == .withImage { + contentView.addSubview(alertImageView) + alertImageView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Layout.imageTopSpacing) + make.centerX.equalToSuperview() + make.size.equalTo(Layout.imageSize) + } + + titleLabel.snp.makeConstraints { make in + make.top.equalTo(alertImageView.snp.bottom).offset(Layout.titleTopSpacing) + make.centerX.equalToSuperview() + make.height.equalTo(Layout.titleHeight) + } + } else { + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Layout.titleTopSpacing) + make.centerX.equalToSuperview() + make.height.equalTo(Layout.titleHeight) + } + } + + contentLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Layout.contentTopSpacing) + make.centerX.equalToSuperview() + make.height.equalTo(Layout.contentHeight) + } + + cancelButton.snp.makeConstraints { make in + make.top.equalTo(contentLabel.snp.bottom).offset(Layout.buttonTopSpacing) + make.leading.equalToSuperview().offset(Layout.buttonHorizontalSpacing) + make.height.equalTo(Layout.buttonHeight) + make.bottom.equalToSuperview().inset(Layout.buttonBottomSpacing) + } + + confirmButton.snp.makeConstraints { make in + make.top.equalTo(contentLabel.snp.bottom).offset(Layout.buttonTopSpacing) + make.leading.equalTo(cancelButton.snp.trailing).offset(Layout.confirmButtonLeadingSpacing) + make.trailing.equalToSuperview().inset(Layout.buttonHorizontalSpacing) + make.height.equalTo(Layout.buttonHeight) + make.width.equalTo(cancelButton) + make.bottom.equalToSuperview().inset(Layout.buttonBottomSpacing) + } + } + + @objc private func didTapDimmedView() { + dismiss(animated: false) + } +} diff --git a/Projects/Presentation/Sources/MyPage/View/MypageView.swift b/Projects/Presentation/Sources/MyPage/View/MypageView.swift index 407ff347..0e47fb68 100644 --- a/Projects/Presentation/Sources/MyPage/View/MypageView.swift +++ b/Projects/Presentation/Sources/MyPage/View/MypageView.swift @@ -45,6 +45,7 @@ final class MypageView: BaseViewController { title = "마이페이지" settingButton.action = #selector(settingButtonTapped) + settingButton.target = self settingButton.tintColor = .black settingButton.image = BitnagilIcon .settingIcon? @@ -60,10 +61,11 @@ final class MypageView: BaseViewController { dividerView.backgroundColor = BitnagilColor.gray99 - tableView.register(MypageTableViewCell.self, forCellReuseIdentifier: MypageTableViewCell.className) + tableView.register(BitnagilChevronTableViewCell.self, forCellReuseIdentifier: BitnagilChevronTableViewCell.className) tableView.dataSource = self tableView.delegate = self tableView.separatorStyle = .none + tableView.bounces = false } override func configureLayout() { @@ -114,7 +116,9 @@ final class MypageView: BaseViewController { } @objc private func settingButtonTapped() { - // TODO: - 추후 설정 페이지 연결 + guard let settingViewModel = Shared.DIContainer.shared.resolve(type: SettingViewModel.self) else { return } + let settingView = SettingView(viewModel: settingViewModel) + navigationController?.pushViewController(settingView, animated: true) } } @@ -132,7 +136,7 @@ extension MypageView: UITableViewDataSource { } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: MypageTableViewCell.className) as? MypageTableViewCell else { + guard let cell = tableView.dequeueReusableCell(withIdentifier: BitnagilChevronTableViewCell.className) as? BitnagilChevronTableViewCell else { return .init() } diff --git a/Projects/Presentation/Sources/Setting/View/Component/SettingHeaderView.swift b/Projects/Presentation/Sources/Setting/View/Component/SettingHeaderView.swift new file mode 100644 index 00000000..5f3a9704 --- /dev/null +++ b/Projects/Presentation/Sources/Setting/View/Component/SettingHeaderView.swift @@ -0,0 +1,67 @@ +// +// SettingHeaderView.swift +// Presentation +// +// Created by 이동현 on 7/27/25. +// + +import SnapKit +import UIKit + +final class SettingHeaderView: UITableViewHeaderFooterView { + private enum Layout { + static let divideLineHeight: CGFloat = 6 + static let titleLabelTopSpacing: CGFloat = 18 + static let titleLabelHeight: CGFloat = 24 + static let titleLabelLeadingSpacing: CGFloat = 20 + } + + private let divideLine = UILabel() + private let titleLabel = UILabel() + private var titleLabelTopConstraint: Constraint? + + override init(reuseIdentifier: String?) { + super.init(reuseIdentifier: reuseIdentifier) + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + divideLine.backgroundColor = BitnagilColor.gray99 + + titleLabel.font = BitnagilFont.init(style: .caption1, weight: .semiBold).font + titleLabel.textColor = BitnagilColor.gray60 + } + + private func configureLayout() { + addSubview(divideLine) + addSubview(titleLabel) + + divideLine.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + make.height.equalTo(Layout.divideLineHeight) + } + + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Layout.titleLabelLeadingSpacing) + make.height.equalTo(Layout.titleLabelHeight) + titleLabelTopConstraint = make.top.equalToSuperview() + .offset(Layout.titleLabelTopSpacing) + .constraint + } + } + + func configure(shouldShowDivider: Bool, title: String) { + let titleLabelTopSpacing: CGFloat = shouldShowDivider + ? Layout.titleLabelTopSpacing + : .zero + + divideLine.isHidden = !shouldShowDivider + titleLabel.text = title + titleLabelTopConstraint?.update(offset: titleLabelTopSpacing) + } +} diff --git a/Projects/Presentation/Sources/Setting/View/SettingView.swift b/Projects/Presentation/Sources/Setting/View/SettingView.swift new file mode 100644 index 00000000..6dccbd98 --- /dev/null +++ b/Projects/Presentation/Sources/Setting/View/SettingView.swift @@ -0,0 +1,376 @@ +// +// SettingViewController.swift +// Presentation +// +// Created by 이동현 on 7/27/25. +// + +import Combine +import SafariServices +import SnapKit +import UIKit + +final class SettingView: BaseViewController { + private enum Layout { + static let tableViewTopSpacing: CGFloat = 32 + static let tableViewHeaderHeight: CGFloat = 48 + static let tableViewRowHeight: CGFloat = 48 + static let tableViewFooterHeight: CGFloat = .zero + } + + private enum CellStyle { + case toggle(title: String) + case button(title: String) + case chevron(title: String) + } + + private enum Section: Int, CaseIterable { + case notification + case information + case account + + var title: String { + switch self { + case .notification: + return "알림" + case .information: + return "정보" + case .account: + return "계정" + } + } + } + + private enum NotificationSection: Int, CaseIterable, CustomStringConvertible { + case general + case push + + var description: String { + switch self { + case .general: + return "서비스 이용 알림" + case .push: + return "푸시알림" + } + } + + var cellStyle: CellStyle { + return .toggle(title: description) + } + } + + private enum InformationSection: Int, CaseIterable, CustomStringConvertible { + case version + case terms + case privacy + + var description: String { + switch self { + case .version: + return "버전" + case .terms: + return "서비스 이용약관" + case .privacy: + return "개인정보처리방침" + } + } + + var cellStyle: CellStyle { + switch self { + case .version: + return .button(title: description) + default: + return .chevron(title: description) + } + } + } + + private enum AccountSection: CaseIterable, CustomStringConvertible { + case logout + case withdrawal + + var description: String { + switch self { + case .logout: + return "로그아웃" + case .withdrawal: + return "탈퇴하기" + } + } + + var cellStyle: CellStyle { + return .chevron(title: description) + } + } + + private let tableView = UITableView(frame: .zero, style: .grouped) + private var cancellables = Set() + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureNavigationBar(navigationStyle: .withBackButton(title: "설정")) + } + + override func configureAttribute() { + view.backgroundColor = .white + + tableView.delegate = self + tableView.dataSource = self + tableView.separatorStyle = .none + tableView.backgroundColor = .white + tableView.register(BitnagilToggleTableViewCell.self, forCellReuseIdentifier: BitnagilToggleTableViewCell.className) + tableView.register(BitnagilButtonTableViewCell.self, forCellReuseIdentifier: BitnagilButtonTableViewCell.className) + tableView.register(BitnagilChevronTableViewCell.self, forCellReuseIdentifier: BitnagilChevronTableViewCell.className) + tableView.register(SettingHeaderView.self, forHeaderFooterViewReuseIdentifier: SettingHeaderView.className) + } + + override func configureLayout() { + view.addSubview(tableView) + + tableView.snp.makeConstraints { make in + make.edges.horizontalEdges.equalToSuperview() + } + } + + override func bind() { + viewModel.output.generalNotificationEnabled + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] isEnabled in + let indexPath = IndexPath(row: NotificationSection.general.rawValue, section: Section.notification.rawValue) + guard + let self, + let cell = self.tableView.cellForRow(at: indexPath) as? BitnagilToggleTableViewCell + else { return } + + cell.configureToggleState(isOn: isEnabled) + }) + .store(in: &cancellables) + + viewModel.output.pushNotificationEnabled + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] isEnabled in + let indexPath = IndexPath(row: NotificationSection.push.rawValue, section: Section.notification.rawValue) + guard + let self, + let cell = self.tableView.cellForRow(at: indexPath) as? BitnagilToggleTableViewCell + else { return } + + cell.configureToggleState(isOn: isEnabled) + }) + .store(in: &cancellables) + + viewModel.output.urlPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] url in + guard let url else { return } + + let safariView = SFSafariViewController(url: url) + self?.present(safariView, animated: true) + }) + .store(in: &cancellables) + + viewModel.output.versionPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] versionType in + let indexPath = IndexPath(row: InformationSection.version.rawValue, section: Section.information.rawValue) + guard + let self, + let cell = self.tableView.cellForRow(at: indexPath) as? BitnagilButtonTableViewCell + else { return } + + switch versionType { + case .needUpdate(let version): + cell.configure(title: "버전\(version)", buttonTitle: "업데이트", isButtonEnabled: true) + case .latest(let version): + cell.configure(title: "버전\(version)", buttonTitle: "최신", isButtonEnabled: false) + } + }) + .store(in: &cancellables) + + viewModel.output.isAuthenticatedPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { isAuthenticated in + // 로그아웃 완료 후 홈 화면으로 + }) + .store(in: &cancellables) + } +} + +extension SettingView: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let section = Section.allCases[indexPath.section] + + switch section { + case .notification: + return + case .information: + let row = InformationSection.allCases[indexPath.row] + switch row { + case .version: + break + case .terms: + viewModel.action(input: .openURL(type: .terms)) + case .privacy: + viewModel.action(input: .openURL(type: .privacy)) + } + case .account: + let alert: BitnagilAlert + let row = AccountSection.allCases[indexPath.row] + switch row { + case .logout: + alert = BitnagilAlert( + alertType: .withImage, + title: "로그아웃 하시겠어요?", + content: "버튼을 누르면 로그인 페이지로 이동해요.", + cancelButtonTitle: "취소", + confirmButtonTitle: "로그아웃", + cancelHandler: nil, + confirmHandler: { [weak self] in + self?.viewModel.action(input: .logout) + }) + case .withdrawal: + alert = BitnagilAlert( + alertType: .withImage, + title: "정말 탈퇴하시겠어요?", + content: "소중한 기록들이 모두 사라져요.", + cancelButtonTitle: "취소", + confirmButtonTitle: "회원탈퇴", + cancelHandler: nil, + confirmHandler: { [weak self] in + self?.viewModel.action(input: .withdrawal) + }) + } + alert.modalPresentationStyle = .overFullScreen + present(alert, animated: false) + } + tableView.deselectRow(at: indexPath, animated: true) + } +} + +extension SettingView: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch Section.allCases[section] { + case .notification: + return NotificationSection.allCases.count + case .information: + return InformationSection.allCases.count + case .account: + return AccountSection.allCases.count + } + } + + func numberOfSections(in tableView: UITableView) -> Int { + return Section.allCases.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let section = Section.allCases[indexPath.section] + + let cellStyle: CellStyle + + switch section { + case .notification: + let row = NotificationSection.allCases[indexPath.row] + cellStyle = row.cellStyle + case .information: + let row = InformationSection.allCases[indexPath.row] + cellStyle = row.cellStyle + case .account: + let row = AccountSection.allCases[indexPath.row] + cellStyle = row.cellStyle + } + + switch cellStyle { + case .toggle(let title): + guard let cell = tableView.dequeueReusableCell(withIdentifier: BitnagilToggleTableViewCell.className, for: indexPath) as? BitnagilToggleTableViewCell else { return .init() } + cell.configure(title: title) + cell.delegate = self + cell.selectionStyle = .none + return cell + case .button(let title): + guard let cell = tableView.dequeueReusableCell(withIdentifier: BitnagilButtonTableViewCell.className, for: indexPath) as? BitnagilButtonTableViewCell else { return .init() } + cell.configure(title: title) + cell.delegate = self + cell.selectionStyle = .none + return cell + case .chevron(let title): + guard let cell = tableView.dequeueReusableCell(withIdentifier: BitnagilChevronTableViewCell.className, for: indexPath) as? BitnagilChevronTableViewCell else { return .init() } + cell.configure(title: title) + return cell + } + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let section = Section.allCases[section] + + guard let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: SettingHeaderView.className) as? SettingHeaderView else { return nil } + + switch section { + case .notification: + let view = UIView() + view.backgroundColor = .white + return view + default: + headerView.configure(shouldShowDivider: true, title: section.title) + return headerView + } + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + let section = Section.allCases[section] + + switch section { + case .notification: + return Layout.tableViewTopSpacing + default: + return Layout.tableViewHeaderHeight + } + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return Layout.tableViewFooterHeight + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return Layout.tableViewRowHeight + } +} + +extension SettingView: BitnagilButtonTableViewCellDelegate { + func bitnagilButtonTableViewCellDidTapButton(_ sender: BitnagilButtonTableViewCell) { + guard let indexPath = tableView.indexPath(for: sender) else { return } + + let section = Section.allCases[indexPath.section] + switch section { + case .information: + let row = InformationSection.allCases[indexPath.row] + switch row { + case .version: + viewModel.action(input: .update) + default: break + } + default: + break + } + } +} + +extension SettingView: BitnagilToggleTableViewCellDelegate { + func bitnagilToggleTableViewCellDidToggle(_ sender: BitnagilToggleTableViewCell, isOn: Bool) { + guard let indexPath = tableView.indexPath(for: sender) else { return } + + let section = Section.allCases[indexPath.section] + switch section { + case .notification: + let row = NotificationSection.allCases[indexPath.row] + switch row { + case .general: + viewModel.action(input: .toggleGeneralNotification) + case .push: + viewModel.action(input: .togglePushNotification) + } + default: + break + } + } +} diff --git a/Projects/Presentation/Sources/Setting/ViewModel/SettingViewModel.swift b/Projects/Presentation/Sources/Setting/ViewModel/SettingViewModel.swift new file mode 100644 index 00000000..a8c8d4b6 --- /dev/null +++ b/Projects/Presentation/Sources/Setting/ViewModel/SettingViewModel.swift @@ -0,0 +1,105 @@ +// +// SettingViewModel.swift +// Presentation +// +// Created by 이동현 on 7/30/25. +// + +import Combine +import Foundation + +final class SettingViewModel: ViewModel { + enum URLType { + case terms + case privacy + + fileprivate var url: URL? { + switch self { + case .terms: + return URL(string: "https://complex-wombat-99f.notion.site/2025-7-20-236f4587491d8071833adfaf8115bce2") + case .privacy: + return URL(string: "https://complex-wombat-99f.notion.site/2025-07-20-236f4587491d80308016eb810692d18b") + } + } + } + + enum VersionType { + case needUpdate(version: String) + case latest(version: String) + } + + enum Input { + case toggleGeneralNotification + case togglePushNotification + case update + case openURL(type: URLType) + case logout + case withdrawal + } + + struct Output { + let generalNotificationEnabled: AnyPublisher + let pushNotificationEnabled: AnyPublisher + let versionPublisher: AnyPublisher + let urlPublisher: AnyPublisher + let isAuthenticatedPublisher: AnyPublisher + } + + private(set) var output: Output + private let versionSubject = CurrentValueSubject(.latest(version: "1.0.0")) + private let generalNoticeEnabledSubject = CurrentValueSubject(false) + private let pushNoticeEnabledSubject = CurrentValueSubject(true) + private let externalURLSubject = PassthroughSubject() + private let authenticatedSubject = PassthroughSubject() + + + init() { + output = .init( + generalNotificationEnabled: generalNoticeEnabledSubject.eraseToAnyPublisher(), + pushNotificationEnabled: pushNoticeEnabledSubject.eraseToAnyPublisher(), + versionPublisher: versionSubject.eraseToAnyPublisher(), + urlPublisher: externalURLSubject.eraseToAnyPublisher(), + isAuthenticatedPublisher: authenticatedSubject.eraseToAnyPublisher()) + } + + func action(input: Input) { + switch input { + case .toggleGeneralNotification: + toggleGeneralNotification() + case .togglePushNotification: + togglePushNotification() + case .update: + updateBitnagil() + case .openURL(type: let type): + externalURLSubject.send(type.url) + case .logout: + logout() + case .withdrawal: + withdrawal() + } + } + + private func toggleGeneralNotification() { + var generalNotificationEnabled = generalNoticeEnabledSubject.value + generalNotificationEnabled.toggle() + generalNoticeEnabledSubject.send(generalNotificationEnabled) + } + + private func togglePushNotification() { + var pushNotificationEnabled = pushNoticeEnabledSubject.value + pushNotificationEnabled.toggle() + pushNoticeEnabledSubject.send(pushNotificationEnabled) + } + + private func updateBitnagil() { + // TODO: - 앱스토어 열기 + } + + private func logout() { + // TODO: - 로그아웃 api + } + + private func withdrawal() { + // TODO: - 회원탈퇴 api + } +}