From 648989d994f84c6d92d00cc38e9799581623fe60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8E=E1=85=AC=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8B?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Wed, 9 Jul 2025 16:58:38 +0900 Subject: [PATCH 1/9] =?UTF-8?q?Feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=A7=80=20=EB=B2=84=ED=8A=BC=20Components?= =?UTF-8?q?=20UI=20=EA=B5=AC=ED=98=84=20(#T3-68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/OnboardingChoiceButton.swift | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 Projects/Presentation/Sources/Onboarding/View/Components/OnboardingChoiceButton.swift diff --git a/Projects/Presentation/Sources/Onboarding/View/Components/OnboardingChoiceButton.swift b/Projects/Presentation/Sources/Onboarding/View/Components/OnboardingChoiceButton.swift new file mode 100644 index 00000000..0dc4ed68 --- /dev/null +++ b/Projects/Presentation/Sources/Onboarding/View/Components/OnboardingChoiceButton.swift @@ -0,0 +1,105 @@ +// +// OnboardingChoiceButton.swift +// Presentation +// +// Created by 최정인 on 7/9/25. +// + +import UIKit + +final class OnboardingChoiceButton: UIButton { + + private enum Layout { + static let cornerRadius: CGFloat = 12 + static let horizontalMargin: CGFloat = 20 + static let mainLabelHeight: CGFloat = 28 + static let subLabelHeight: CGFloat = 20 + } + + private let stackView = UIStackView() + private let mainLabel = UILabel() + private var subLabel: UILabel? = nil + + private var isChecked: Bool = false { + didSet { + updateButtonAttribute() + } + } + + private var onboardingChoice: OnboardingChoice + init(onboardingChoice: OnboardingChoice) { + self.onboardingChoice = onboardingChoice + super.init(frame: .zero) + configureAttribute() + configureLayout() + updateButtonAttribute() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + backgroundColor = .white + layer.borderWidth = 1 + layer.cornerRadius = Layout.cornerRadius + + stackView.do { + $0.axis = .vertical + $0.alignment = .leading + $0.spacing = 2 + $0.isUserInteractionEnabled = false + } + + guard let subTitle = onboardingChoice.subTitle else { + mainLabel.do { + $0.text = onboardingChoice.mainTitle + $0.font = BitnagilFont(style: .body1, weight: .regular).font + $0.textColor = BitnagilColor.gray50 + } + return + } + + mainLabel.do { + $0.text = onboardingChoice.mainTitle + $0.font = BitnagilFont(style: .subtitle1, weight: .semiBold).font + $0.textColor = BitnagilColor.gray50 + } + + subLabel = UILabel().then { + $0.text = subTitle + $0.font = BitnagilFont(style: .body2, weight: .regular).font + $0.textColor = BitnagilColor.gray50 + } + } + + private func configureLayout() { + addSubview(stackView) + stackView.addArrangedSubview(mainLabel) + if let subLabel { + stackView.addArrangedSubview(subLabel) + mainLabel.snp.makeConstraints { make in + make.height.equalTo(Layout.mainLabelHeight) + } + subLabel.snp.makeConstraints { make in + make.height.equalTo(Layout.subLabelHeight) + } + } + + stackView.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(Layout.horizontalMargin) + make.centerY.equalToSuperview() + } + } + + private func updateButtonAttribute() { + backgroundColor = isChecked ? BitnagilColor.lightBlue75 : .white + layer.borderColor = (isChecked ? BitnagilColor.lightBlue200 : .white)?.cgColor + mainLabel.textColor = isChecked ? BitnagilColor.navy500 : BitnagilColor.gray50 + subLabel?.textColor = isChecked ? BitnagilColor.navy500 : BitnagilColor.gray50 + } + + func updateButtonState() { + self.isChecked.toggle() + } +} From 96b33ad0fc06cd951ef71cfa21d1865002f9e548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8E=E1=85=AC=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8B?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Fri, 11 Jul 2025 20:44:35 +0900 Subject: [PATCH 2/9] =?UTF-8?q?Feat:=20GradientProgressBar=20UI=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gradient 색상 Asset 추가 - GradientProgressBar를 포함한 navigationItem 구현 --- .../Colors.xcassets/Gradient/Contents.json | 6 ++ .../GradientLeft.colorset/Contents.json | 20 ++++++ .../GradientRight.colorset/Contents.json | 20 ++++++ .../Common/DesignSystem/BitnagilColor.swift | 4 ++ .../Common/Extensions/UIViewController+.swift | 14 ++++- .../Components/GradientProgressView.swift | 39 ++++++++++++ .../View/Components/ProgressBarView.swift | 63 +++++++++++++++++++ 7 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 Projects/Presentation/Resources/Colors.xcassets/Gradient/Contents.json create mode 100644 Projects/Presentation/Resources/Colors.xcassets/Gradient/GradientLeft.colorset/Contents.json create mode 100644 Projects/Presentation/Resources/Colors.xcassets/Gradient/GradientRight.colorset/Contents.json create mode 100644 Projects/Presentation/Sources/Onboarding/View/Components/GradientProgressView.swift create mode 100644 Projects/Presentation/Sources/Onboarding/View/Components/ProgressBarView.swift diff --git a/Projects/Presentation/Resources/Colors.xcassets/Gradient/Contents.json b/Projects/Presentation/Resources/Colors.xcassets/Gradient/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Projects/Presentation/Resources/Colors.xcassets/Gradient/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Colors.xcassets/Gradient/GradientLeft.colorset/Contents.json b/Projects/Presentation/Resources/Colors.xcassets/Gradient/GradientLeft.colorset/Contents.json new file mode 100644 index 00000000..5f4fe879 --- /dev/null +++ b/Projects/Presentation/Resources/Colors.xcassets/Gradient/GradientLeft.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xCF", + "red" : "0xA9" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Colors.xcassets/Gradient/GradientRight.colorset/Contents.json b/Projects/Presentation/Resources/Colors.xcassets/Gradient/GradientRight.colorset/Contents.json new file mode 100644 index 00000000..116839bf --- /dev/null +++ b/Projects/Presentation/Resources/Colors.xcassets/Gradient/GradientRight.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xB3", + "green" : "0xCD", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilColor.swift b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilColor.swift index 8e1a143d..773384b4 100644 --- a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilColor.swift +++ b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilColor.swift @@ -14,6 +14,10 @@ enum BitnagilColor { static let kakao = UIColor(named: "Kakao", in: bundle, compatibleWith: nil) + // MARK: - Gradient + static let gradientLeft = UIColor(named: "GradientLeft", in: bundle, compatibleWith: nil) + static let gradientRight = UIColor(named: "GradientRight", in: bundle, compatibleWith: nil) + // MARK: - Emotion Colors static let happy = UIColor(named: "EmotionHappy", in: bundle, compatibleWith: nil) static let lethargy = UIColor(named: "EmotionLethargy", in: bundle, compatibleWith: nil) diff --git a/Projects/Presentation/Sources/Common/Extensions/UIViewController+.swift b/Projects/Presentation/Sources/Common/Extensions/UIViewController+.swift index 556f0bc6..0fd39474 100644 --- a/Projects/Presentation/Sources/Common/Extensions/UIViewController+.swift +++ b/Projects/Presentation/Sources/Common/Extensions/UIViewController+.swift @@ -13,11 +13,16 @@ extension UIViewController { switch navigationStyle { case .hidden: navigationController?.setNavigationBarHidden(true, animated: false) + case .withBackButton(let title): navigationController?.setNavigationBarHidden(false, animated: false) self.title = title configureDefaultBackButton() - } + + case .withPrograssBar(let step, let stepCount): + navigationController?.setNavigationBarHidden(false, animated: false) + configureDefaultBackButton() + configureProgressNavigationBar(step: step, stepCount: stepCount) } func configureDefaultBackButton() { @@ -30,6 +35,12 @@ extension UIViewController { navigationItem.leftBarButtonItem = backButton } + private func configureProgressNavigationBar(step: Int, stepCount: Int) { + self.title = "" + let progressView = ProgressBarView(step: step, stepCount: stepCount) + navigationItem.titleView = progressView + } + @objc private func popViewController() { navigationController?.popViewController(animated: true) } @@ -38,4 +49,5 @@ extension UIViewController { enum NavigationBarStyle { case hidden case withBackButton(title: String) + case withPrograssBar(step: Int, stepCount: Int) } diff --git a/Projects/Presentation/Sources/Onboarding/View/Components/GradientProgressView.swift b/Projects/Presentation/Sources/Onboarding/View/Components/GradientProgressView.swift new file mode 100644 index 00000000..94420d7f --- /dev/null +++ b/Projects/Presentation/Sources/Onboarding/View/Components/GradientProgressView.swift @@ -0,0 +1,39 @@ +// +// GradientProgressView.swift +// Presentation +// +// Created by 최정인 on 7/9/25. +// + +import UIKit + +final class GradientProgressView: UIView { + + private let barHeight: CGFloat = 5 + private let gradientLayer = CAGradientLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + configureGradient() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureGradient() { + gradientLayer.colors = [ + BitnagilColor.gradientLeft?.cgColor ?? UIColor.blue.cgColor, + BitnagilColor.gradientRight?.cgColor ?? UIColor.red.cgColor, + ] + gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + gradientLayer.cornerRadius = barHeight / 2 + layer.addSublayer(gradientLayer) + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } +} diff --git a/Projects/Presentation/Sources/Onboarding/View/Components/ProgressBarView.swift b/Projects/Presentation/Sources/Onboarding/View/Components/ProgressBarView.swift new file mode 100644 index 00000000..74d1f655 --- /dev/null +++ b/Projects/Presentation/Sources/Onboarding/View/Components/ProgressBarView.swift @@ -0,0 +1,63 @@ +// +// ProgressBarView.swift +// Presentation +// +// Created by 최정인 on 7/9/25. +// + +import UIKit + +final class ProgressBarView: UIView { + + private enum Layout { + static let maxWidth: CGFloat = 400 + static let barHeight: CGFloat = 5 + } + + private let backgroundView = UIView() + private let progressView = GradientProgressView() + + init(step: Int, stepCount: Int) { + super.init(frame: .zero) + configureAttribute() + configureLayout(step: step, stepCount: stepCount) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + return CGSize(width: Layout.maxWidth, height: Layout.barHeight) + } + + private func configureAttribute() { + backgroundView.do { + $0.frame = bounds + $0.backgroundColor = .white + $0.layer.cornerRadius = Layout.barHeight / 2 + $0.layer.masksToBounds = true + } + + progressView.do { + $0.layer.cornerRadius = Layout.barHeight / 2 + $0.layer.masksToBounds = true + } + } + + private func configureLayout(step: Int, stepCount: Int) { + addSubview(backgroundView) + addSubview(progressView) + + let progressRatio = min(max(CGFloat(step) / CGFloat(stepCount), 0.0), 1.0) + backgroundView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.top.bottom.equalToSuperview() + } + + progressView.snp.makeConstraints { make in + make.leading.top.bottom.equalTo(backgroundView) + make.width.equalTo(backgroundView).multipliedBy(progressRatio) + } + } +} From 91edae6bc919d720a8ea018b5947ef7e30232a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8E=E1=85=AC=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8B?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Fri, 11 Jul 2025 20:46:03 +0900 Subject: [PATCH 3/9] =?UTF-8?q?Refactor:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=A7=80=20UI=20=EC=88=98=EC=A0=95=20(#T3-68?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OnboardingChoiceProtocol 구현 - 온보딩 선택 화면에서 해당 프로토콜을 채택한 타입이라면 OnboardingChoiceButton을 그릴 수 있도록 수정 --- .../Onboarding/Model/OnboardingChoiceProtocol.swift | 11 +++++++++++ .../View/Components/OnboardingChoiceButton.swift | 11 ++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceProtocol.swift diff --git a/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceProtocol.swift b/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceProtocol.swift new file mode 100644 index 00000000..c8dc9a3a --- /dev/null +++ b/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceProtocol.swift @@ -0,0 +1,11 @@ +// +// OnboardingChoiceProtocol.swift +// Presentation +// +// Created by 최정인 on 7/11/25. +// + +protocol OnboardingChoiceProtocol { + var mainTitle: String { get } + var subTitle: String? { get } +} diff --git a/Projects/Presentation/Sources/Onboarding/View/Components/OnboardingChoiceButton.swift b/Projects/Presentation/Sources/Onboarding/View/Components/OnboardingChoiceButton.swift index 0dc4ed68..4a8ce792 100644 --- a/Projects/Presentation/Sources/Onboarding/View/Components/OnboardingChoiceButton.swift +++ b/Projects/Presentation/Sources/Onboarding/View/Components/OnboardingChoiceButton.swift @@ -12,6 +12,7 @@ final class OnboardingChoiceButton: UIButton { private enum Layout { static let cornerRadius: CGFloat = 12 static let horizontalMargin: CGFloat = 20 + static let stackViewSpacing: CGFloat = 2 static let mainLabelHeight: CGFloat = 28 static let subLabelHeight: CGFloat = 20 } @@ -26,8 +27,8 @@ final class OnboardingChoiceButton: UIButton { } } - private var onboardingChoice: OnboardingChoice - init(onboardingChoice: OnboardingChoice) { + private var onboardingChoice: OnboardingChoiceProtocol + init(onboardingChoice: OnboardingChoiceProtocol) { self.onboardingChoice = onboardingChoice super.init(frame: .zero) configureAttribute() @@ -47,7 +48,7 @@ final class OnboardingChoiceButton: UIButton { stackView.do { $0.axis = .vertical $0.alignment = .leading - $0.spacing = 2 + $0.spacing = Layout.stackViewSpacing $0.isUserInteractionEnabled = false } @@ -99,7 +100,7 @@ final class OnboardingChoiceButton: UIButton { subLabel?.textColor = isChecked ? BitnagilColor.navy500 : BitnagilColor.gray50 } - func updateButtonState() { - self.isChecked.toggle() + func updateButtonState(isChecked: Bool) { + self.isChecked = isChecked } } From edcaf62696ee27a8efea2b31128a03cb027cfd8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8E=E1=85=AC=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8B?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Fri, 11 Jul 2025 20:47:06 +0900 Subject: [PATCH 4/9] =?UTF-8?q?Feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9=EB=90=A0=20enum=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=A0=95=EC=9D=98=20(#T3-68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OnboardingType (온보딩 유형 값) - OnboardingChoiceType (온보딩 선택지 값) --- .../Model/OnboardingChoiceType.swift | 130 ++++++++++++++++++ .../Onboarding/Model/OnboardingType.swift | 67 +++++++++ 2 files changed, 197 insertions(+) create mode 100644 Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceType.swift create mode 100644 Projects/Presentation/Sources/Onboarding/Model/OnboardingType.swift diff --git a/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceType.swift b/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceType.swift new file mode 100644 index 00000000..3c476039 --- /dev/null +++ b/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceType.swift @@ -0,0 +1,130 @@ +// +// OnboardingChoiceType.swift +// Presentation +// +// Created by 최정인 on 7/9/25. +// + +enum OnboardingChoiceType: CaseIterable, OnboardingChoiceProtocol { + case morningTime + case eveningTime + case allTime + + case never + case rarely + case sometimes + case often + + case stability + case connection + case growth + case vitality + + case once + case twoToThree + case fourOrMore + case notSure + + var onboardingType: OnboardingType { + switch self { + case .morningTime: .time + case .eveningTime: .time + case .allTime: .time + + case .never: .frequency + case .rarely: .frequency + case .sometimes: .frequency + case .often: .frequency + + case .stability: .feeling + case .connection: .feeling + case .growth: .feeling + case .vitality: .feeling + + case .once: .outdoorGoal + case .twoToThree: .outdoorGoal + case .fourOrMore: .outdoorGoal + case .notSure: .outdoorGoal + } + } + + var mainTitle: String { + switch self { + case .morningTime: "아침을 잘 시작하고 싶어요." + case .eveningTime: "저녁을 편안하게 마무리하고 싶어요." + case .allTime: "언제든 상관 없어요." + + case .never: "밖에 나가지 않고 집에서만 지냈어요." + case .rarely: "잠깐 외출했어요." + case .sometimes: "가끔 나가요." + case .often: "자주 외출해요." + + case .stability: "안정감" + case .connection: "연결감" + case .growth: "성장감" + case .vitality: "생동감" + + case .once: "시작이 더 중요해요." + case .twoToThree: "너무 무리하지 않아도 괜찮아요." + case .fourOrMore: "이 정도면 충분히 활력 있는 한 주가 될거에요." + case .notSure: "목표 선택을 도와드릴게요!" + } + } + + var subTitle: String? { + switch self { + case .stability: + return "하루를 편안하게 보내고 싶어요." + case .connection: + return "누군가와 함께 있다는 느낌이 필요해요." + case .growth: + return "작은 변화라도 시작하고 싶어요." + case .vitality: + return "무기력을 이겨내고 활력을 찾고싶어요." + + case .once: + return "일주일에 1회" + case .twoToThree: + return "일주일에 2~3회" + case .fourOrMore: + return "일주일에 4회 이상" + case .notSure: + return "아직 잘 모르겠어요" + + default: + return nil + } + } + + var resultTitle: String? { + switch self { + case .morningTime: + return "아침루틴" + case .eveningTime: + return "저녁루틴" + case .allTime: + return "전체루틴" + + case .stability: + return "안정감" + case .connection: + return "연결감" + case .growth: + return "성장감" + case .vitality: + return "생동감" + + case .once: + return "주 1회 외출" + case .twoToThree: + return "주 3회 외출" + case .fourOrMore: + return "주 4회 이상 외출" + case .notSure: + return "최소한의 외출" + + default: + return nil + } + } +} diff --git a/Projects/Presentation/Sources/Onboarding/Model/OnboardingType.swift b/Projects/Presentation/Sources/Onboarding/Model/OnboardingType.swift new file mode 100644 index 00000000..be7b99b9 --- /dev/null +++ b/Projects/Presentation/Sources/Onboarding/Model/OnboardingType.swift @@ -0,0 +1,67 @@ +// +// OnboardingType.swift +// Presentation +// +// Created by 최정인 on 7/11/25. +// + +enum OnboardingType: CaseIterable { + case time + case frequency + case feeling + case outdoorGoal + + var step: Int { + switch self { + case .time: 1 + case .frequency: 2 + case .feeling: 3 + case .outdoorGoal: 4 + } + } + + var mainTitle: String { + switch self { + case .time: "어떤 시간대를\n더 잘 보내고 싶나요?" + case .frequency: "최근 얼마나 자주\n바깥 바람을 쐬시나요?" + case .feeling: "요즘 어떤 회복이\n필요하신가요?" + case .outdoorGoal: "작지만 의미 있는 변화를 위해,\n일주일에 몇 번 외출하고 싶으신가요?" + } + } + + var subTitle: String? { + switch self { + case .time: nil + case .frequency: nil + case .feeling: "여러 개 선택할 수 있어요!" + case .outdoorGoal: "무리하지 않는 선에서, 나만의 외출 목표를 정해보세요." + } + } + + var choices: [OnboardingChoiceType] { + switch self { + case .time: + return [OnboardingChoiceType.morningTime, + OnboardingChoiceType.eveningTime, + OnboardingChoiceType.allTime] + + case .frequency: + return [OnboardingChoiceType.never, + OnboardingChoiceType.rarely, + OnboardingChoiceType.sometimes, + OnboardingChoiceType.often] + + case .feeling: + return [OnboardingChoiceType.stability, + OnboardingChoiceType.connection, + OnboardingChoiceType.growth, + OnboardingChoiceType.vitality] + + case .outdoorGoal: + return [OnboardingChoiceType.once, + OnboardingChoiceType.twoToThree, + OnboardingChoiceType.fourOrMore, + OnboardingChoiceType.notSure] + } + } +} From 2e6f908afb8aa0db7c8182320b26742c55a1a64a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8E=E1=85=AC=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8B?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Fri, 11 Jul 2025 20:48:08 +0900 Subject: [PATCH 5/9] =?UTF-8?q?Feat:=20OnboardingView=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#T3-68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 온보딩 선택 화면에서 재사용될 OnboardingView 구현 --- .../Onboarding/View/OnboardingView.swift | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift diff --git a/Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift b/Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift new file mode 100644 index 00000000..881fae74 --- /dev/null +++ b/Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift @@ -0,0 +1,224 @@ +// +// OnboardingView.swift +// Presentation +// +// Created by 최정인 on 7/8/25. +// + +import UIKit +import Combine + +final class OnboardingView: BaseViewController { + + private enum Layout { + static let horizontalMargin: CGFloat = 20 + static let mainLabelHeight: CGFloat = 60 + static let subLabelTopSpacing: CGFloat = 10 + static let choiceButtonHeight: CGFloat = 52 + static let choiceButtonHeightWithSubLabel: CGFloat = 84 + static let choiceStackViewSpacing: CGFloat = 12 + static let choiceStackViewTopSpacing: CGFloat = 28 + static let nextButtonHeight: CGFloat = 54 + static let nextButtonBottomSpacing: CGFloat = 20 + + static var mainLabelTopSpacing: CGFloat { + let height = UIScreen.main.bounds.height + if height <= 667 { return 12 } + else { return 32 } + } + } + + private let onboarding: OnboardingType + private let mainLabel = UILabel() + private var subLabel: UILabel? = nil + private let choiceStackView = UIStackView() + private var choiceButtons: [OnboardingChoiceType: OnboardingChoiceButton] = [:] + private let nextButton = PrimaryButton(buttonState: .disabled, buttonTitle: "다음") + private var cancellables: Set + + public init(viewModel: OnboardingViewModel, onboarding: OnboardingType) { + self.onboarding = onboarding + cancellables = [] + super.init(viewModel: viewModel) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + let stepCount = OnboardingType.allCases.count + 1 + configureNavigationBar(navigationStyle: .withPrograssBar(step: onboarding.step, stepCount: stepCount)) + + self.viewModel.action(input: .fetchOnboardingChoice(onboarding: onboarding)) + } + + public override func configureAttribute() { + mainLabel.do { + $0.attributedText = BitnagilFont(style: .title2, weight: .bold).attributedString(text: onboarding.mainTitle) + $0.textColor = BitnagilColor.navy500 + $0.numberOfLines = 2 + $0.textAlignment = .left + } + + if let subTitle = onboarding.subTitle { + subLabel = UILabel().then { + $0.attributedText = BitnagilFont(style: .body2, weight: .medium).attributedString(text: subTitle) + $0.textColor = BitnagilColor.gray50 + $0.numberOfLines = 2 + $0.textAlignment = .left + } + } + + choiceStackView.do { + $0.axis = .vertical + $0.spacing = Layout.choiceStackViewSpacing + } + + for (index, choice) in onboarding.choices.enumerated() { + let choiceButton = OnboardingChoiceButton(onboardingChoice: choice) + choiceButton.tag = index + + choiceButton.addAction(UIAction { [weak self] _ in + self?.viewModel.action(input: .selectOnboardingChoice(selectedChoice: choice)) + }, for: .touchUpInside) + choiceButtons[choice] = choiceButton + choiceStackView.addArrangedSubview(choiceButton) + + choiceButton.snp.makeConstraints { make in + make.height.equalTo(choice.subTitle == nil ? Layout.choiceButtonHeight : Layout.choiceButtonHeightWithSubLabel) + } + } + + nextButton.addAction(UIAction { _ in + self.goNextStep() + }, for: .touchUpInside) + } + + public override func configureLayout() { + let safeArea = view.safeAreaLayoutGuide + view.backgroundColor = BitnagilColor.gray99 + + view.addSubview(mainLabel) + view.addSubview(choiceStackView) + view.addSubview(nextButton) + + var previousView = mainLabel + mainLabel.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.top.equalTo(safeArea).offset(Layout.mainLabelTopSpacing) + make.height.equalTo(Layout.mainLabelHeight) + } + + if let subLabel { + view.addSubview(subLabel) + + subLabel.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.top.equalTo(mainLabel.snp.bottom).offset(Layout.subLabelTopSpacing) + } + previousView = subLabel + } + + choiceStackView.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.top.equalTo(previousView.snp.bottom).offset(Layout.choiceStackViewTopSpacing) + } + + nextButton.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.bottom.equalTo(safeArea).inset(Layout.nextButtonBottomSpacing) + make.height.equalTo(Layout.nextButtonHeight) + } + } + + public override func bind() { + viewModel.output.timeOnboardingChoicePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] timeChoice in + self?.updateOnboardingChoice(onboardingChoice: timeChoice) + } + .store(in: &cancellables) + + viewModel.output.frequencyOnboardingChoicePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] frequencyChoice in + self?.updateOnboardingChoice(onboardingChoice: frequencyChoice) + } + .store(in: &cancellables) + + viewModel.output.feelingOnboardingChoicePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] feelingChoices in + self?.updateOnboardingChoices(onboardingChoices: feelingChoices) + } + .store(in: &cancellables) + + viewModel.output.outdoorGoalOnboardingChoicePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] outdoorChoice in + self?.updateOnboardingChoice(onboardingChoice: outdoorChoice) + } + .store(in: &cancellables) + + viewModel.output.nextButtonPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] canGoNext in + self?.nextButton.updateButtonState(buttonState: canGoNext ? .default : .disabled) + } + .store(in: &cancellables) + } + + private func updateOnboardingChoice(onboardingChoice: OnboardingChoiceType?) { + choiceButtons.forEach { choice in + if choice.key == onboardingChoice { + choice.value.updateButtonState(isChecked: true) + } else { + choice.value.updateButtonState(isChecked: false) + } + } + } + + private func updateOnboardingChoices(onboardingChoices: Set) { + choiceButtons.forEach { choice in + if onboardingChoices.contains(choice.key) { + choice.value.updateButtonState(isChecked: true) + } else { + choice.value.updateButtonState(isChecked: false) + } + } + } + + private func goNextStep() { + var nextStep: OnboardingType? + switch onboarding { + case .time: + nextStep = .frequency + case .frequency: + nextStep = .feeling + case .feeling: + nextStep = .outdoorGoal + case .outdoorGoal: + nextStep = nil + } + + var nextView: UIViewController? + if let nextStep { + nextView = OnboardingView(viewModel: viewModel, onboarding: nextStep) + } else { + nextView = OnboardingResultView(viewModel: viewModel) + } + guard let nextView else { return } + self.navigationController?.pushViewController(nextView, animated: true) + } +} From d893e7a58a3578d1c3174d045b0b3d8e19eca6b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8E=E1=85=AC=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8B?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Fri, 11 Jul 2025 20:49:13 +0900 Subject: [PATCH 6/9] =?UTF-8?q?Feat:=20OnboardingResultView=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#T3-68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 온보딩 결과 화면 UI 구현 - 특정 텍스트만 semiBold로 보여지기 위해 NSAttributedString extension 구현 --- .../Extensions/NSAttributedString+.swift | 30 +++ .../View/OnboardingResultView.swift | 200 ++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 Projects/Presentation/Sources/Common/Extensions/NSAttributedString+.swift create mode 100644 Projects/Presentation/Sources/Onboarding/View/OnboardingResultView.swift diff --git a/Projects/Presentation/Sources/Common/Extensions/NSAttributedString+.swift b/Projects/Presentation/Sources/Common/Extensions/NSAttributedString+.swift new file mode 100644 index 00000000..424e06e2 --- /dev/null +++ b/Projects/Presentation/Sources/Common/Extensions/NSAttributedString+.swift @@ -0,0 +1,30 @@ +// +// NSAttributedString+.swift +// Presentation +// +// Created by 최정인 on 7/11/25. +// + +import Foundation + +extension NSAttributedString { + static func highlighted(text: String, highlightText: String) -> NSAttributedString { + let attributedString = NSMutableAttributedString(string: text) + + attributedString.addAttribute( + .font, + value: BitnagilFont(style: .body2, weight: .regular).font, + range: NSRange(location: 0, length: text.count) + ) + + if let range = text.range(of: highlightText) { + let nsRange = NSRange(range, in: text) + attributedString.addAttribute( + .font, + value: BitnagilFont(style: .body2, weight: .semiBold).font, + range: nsRange + ) + } + return attributedString + } +} diff --git a/Projects/Presentation/Sources/Onboarding/View/OnboardingResultView.swift b/Projects/Presentation/Sources/Onboarding/View/OnboardingResultView.swift new file mode 100644 index 00000000..9ad68f3c --- /dev/null +++ b/Projects/Presentation/Sources/Onboarding/View/OnboardingResultView.swift @@ -0,0 +1,200 @@ +// +// OnboardingResultView.swift +// Presentation +// +// Created by 최정인 on 7/10/25. +// + +import UIKit +import Combine + +final class OnboardingResultView: BaseViewController { + + private enum Layout { + static let horizontalMargin: CGFloat = 20 + static let mainLabelHeight: CGFloat = 60 + static let subLabelTopSpacing: CGFloat = 10 + static let subLabelHeight: CGFloat = 20 + static let resultStackViewTopSpacing: CGFloat = 4 + static let resultStackViewSpacing: CGFloat = 2 + static let graphicTopSpacing: CGFloat = 36 + static let graphicBotttomSpacing: CGFloat = 20 + + static var mainLabelTopSpacing: CGFloat { + let height = UIScreen.main.bounds.height + if height <= 667 { return 12 } + else { return 32 } + } + } + + private let mainLabel = UILabel() + private let resultStackView = UIStackView() + private let subLabel = UILabel() + private var timeResultLabel = UILabel() + private var feelingResultLabel = UILabel() + private var outdoorResultLabel = UILabel() + private let graphicView = UIView() + private var cancellables: Set + + public override init(viewModel: OnboardingViewModel) { + cancellables = [] + super.init(viewModel: viewModel) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + viewModel.action(input: .makeOnboardingResult) + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + UIView.animate(withDuration: 0.5, delay: 3, options: .curveEaseInOut, animations: { + self.view.alpha = 0.0 + }, completion: { [weak self] finished in + guard let self else { return } + let recommendedRoutineView = RecommendedRoutineView(viewModel: self.viewModel) + self.navigationController?.pushViewController(recommendedRoutineView, animated: true) + }) + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + let stepCount = OnboardingType.allCases.count + 1 + configureNavigationBar(navigationStyle: .withPrograssBar(step: stepCount, stepCount: stepCount)) + } + + public override func configureAttribute() { + mainLabel.do { + let text = "이제 당신에게\n꼭 맞는 루틴을 제안해드릴게요." + $0.attributedText = BitnagilFont(style: .title2, weight: .bold).attributedString(text: text) + $0.textColor = BitnagilColor.navy500 + $0.numberOfLines = 2 + $0.textAlignment = .left + } + + subLabel.do { + $0.text = "당신은 지금" + $0.font = BitnagilFont(style: .body2, weight: .medium).font + $0.textColor = BitnagilColor.gray30 + } + + resultStackView.do { + $0.axis = .vertical + $0.spacing = Layout.resultStackViewSpacing + } + + [timeResultLabel, feelingResultLabel, outdoorResultLabel].forEach { label in + label.do { + $0.textColor = BitnagilColor.gray30 + } + } + graphicView.do { + $0.backgroundColor = BitnagilColor.gray90 + } + } + + public override func configureLayout() { + let safeArea = view.safeAreaLayoutGuide + view.backgroundColor = BitnagilColor.gray99 + + view.addSubview(mainLabel) + view.addSubview(subLabel) + view.addSubview(resultStackView) + resultStackView.addArrangedSubview(timeResultLabel) + resultStackView.addArrangedSubview(feelingResultLabel) + resultStackView.addArrangedSubview(outdoorResultLabel) + view.addSubview(graphicView) + + mainLabel.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.top.equalTo(safeArea).offset(Layout.mainLabelTopSpacing) + make.height.equalTo(Layout.mainLabelHeight) + } + + subLabel.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.top.equalTo(mainLabel.snp.bottom).offset(Layout.subLabelTopSpacing) + make.height.equalTo(Layout.subLabelHeight) + } + + resultStackView.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.top.equalTo(subLabel.snp.bottom).offset(Layout.resultStackViewTopSpacing) + } + + [timeResultLabel, feelingResultLabel, outdoorResultLabel].forEach { label in + label.snp.makeConstraints { make in + make.height.equalTo(Layout.subLabelHeight) + } + } + + graphicView.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.top.equalTo(resultStackView.snp.bottom).offset(Layout.graphicTopSpacing) + make.bottom.equalTo(safeArea).inset(Layout.graphicBotttomSpacing) + } + } + + public override func bind() { + viewModel.output.onboardingResultPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] onboardingResults in + self?.updateResultLabels(results: onboardingResults) + } + .store(in: &cancellables) + } + + private func updateResultLabels(results: [String]) { + guard results.count == 3 else { return } + let timeResult = results[0] + let feelingResult = results[1] + let outdoorResult = results[2] + + updateTimeResultLabel(timeResult: timeResult) + updateFeelingResultLabel(feelingResult: feelingResult) + updateOutdoorResultLabel(outdoorResult: outdoorResult) + } + + private func updateTimeResultLabel(timeResult: String) { + let baseText: String + + switch timeResult { + case "아침루틴": + baseText = "• 아침루틴을 만들고 싶고" + case "저녁루틴": + baseText = "• 저녁루틴을 만들고 싶고" + case "전체루틴": + baseText = "• 전체루틴을 회복하고 싶고" + default: + baseText = "" + } + + timeResultLabel.do { + $0.attributedText = NSAttributedString.highlighted(text: baseText, highlightText: timeResult) + } + } + + private func updateFeelingResultLabel(feelingResult: String) { + let baseText = "• \(feelingResult)을 원하는 중이에요" + feelingResultLabel.do { + $0.attributedText = NSAttributedString.highlighted(text: baseText, highlightText: feelingResult) + } + } + + private func updateOutdoorResultLabel(outdoorResult: String) { + let baseText = "• \(outdoorResult)을 목표로 해볼게요!" + outdoorResultLabel.do { + $0.attributedText = NSAttributedString.highlighted(text: baseText, highlightText: outdoorResult) + } + } +} From 8192d23e3848765fde5aebcc5f13d46adbc4454d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8E=E1=85=AC=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8B?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Fri, 11 Jul 2025 20:49:45 +0900 Subject: [PATCH 7/9] =?UTF-8?q?Feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EB=A3=A8=ED=8B=B4=20UI=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#T3-68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Onboarding/Model/RecommendedRoutine.swift | 13 ++ .../View/RecommendedRoutineView.swift | 178 ++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift create mode 100644 Projects/Presentation/Sources/Onboarding/View/RecommendedRoutineView.swift diff --git a/Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift b/Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift new file mode 100644 index 00000000..671e2158 --- /dev/null +++ b/Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift @@ -0,0 +1,13 @@ +// +// RecommendedRoutine.swift +// Presentation +// +// Created by 최정인 on 7/11/25. +// + +// TODO: 추후 루틴에 대한 값이 명확해진다면 수정할 가능성이 높습니다. (현재는 View만을 위한 모델) +struct RecommendedRoutine: OnboardingChoiceProtocol, Hashable { + let id: Int + let mainTitle: String + let subTitle: String? +} diff --git a/Projects/Presentation/Sources/Onboarding/View/RecommendedRoutineView.swift b/Projects/Presentation/Sources/Onboarding/View/RecommendedRoutineView.swift new file mode 100644 index 00000000..a04c219c --- /dev/null +++ b/Projects/Presentation/Sources/Onboarding/View/RecommendedRoutineView.swift @@ -0,0 +1,178 @@ +// +// RecommendedRoutineView.swift +// Presentation +// +// Created by 최정인 on 7/11/25. +// + +import UIKit +import Combine + +final class RecommendedRoutineView: BaseViewController { + + private enum Layout { + static let horizontalMargin: CGFloat = 20 + static let mainLabelHeight: CGFloat = 60 + static let subLabelTopSpacing: CGFloat = 10 + static let subLabelHeight: CGFloat = 40 + static let routineStackViewSpacing: CGFloat = 12 + static let routineStackViewTopSpacing: CGFloat = 28 + static let routineButtonHeight: CGFloat = 84 + static let registerButtonHeight: CGFloat = 54 + static let registerButtonBottomSpacing: CGFloat = 20 + + static var mainLabelTopSpacing: CGFloat { + let height = UIScreen.main.bounds.height + if height <= 667 { return 12 } + else { return 32 } + } + } + + private let mainLabel = UILabel() + private var subLabel = UILabel() + private let recommendedRoutineStackView = UIStackView() + private var recommendedRoutines: [Int: OnboardingChoiceButton] = [:] + private let registerButton = PrimaryButton(buttonState: .disabled, buttonTitle: "등록하기") + private var cancellables: Set + + public override init(viewModel: OnboardingViewModel) { + cancellables = [] + super.init(viewModel: viewModel) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + viewModel.action(input: .fetchRecommendedRoutine) + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + let stepCount = OnboardingType.allCases.count + 1 + configureNavigationBar(navigationStyle: .withPrograssBarWithCustomBackButton(step: stepCount, stepCount: stepCount)) + } + + public override func configureAttribute() { + mainLabel.do { + let text = "당신만의 추천 루틴이\n생성되었어요!" + $0.attributedText = BitnagilFont(style: .title2, weight: .bold).attributedString(text: text) + $0.textColor = BitnagilColor.navy500 + $0.numberOfLines = 2 + $0.textAlignment = .left + } + + subLabel.do { + let text = "당신의 생활 패턴과 목표에 맞춰 구성된 맞춤 루틴이에요.\n원하는 루틴을 선택해서 가볍게 시작해보세요." + $0.attributedText = BitnagilFont(style: .body2, weight: .medium).attributedString(text: text) + $0.textColor = BitnagilColor.gray50 + $0.numberOfLines = 2 + $0.textAlignment = .left + } + + recommendedRoutineStackView.do { + $0.axis = .vertical + $0.spacing = Layout.routineStackViewSpacing + } + + registerButton.addAction(UIAction { [weak self] _ in + self?.viewModel.action(input: .registerRecommendedRoutine) + }, for: .touchUpInside) + } + + public override func configureLayout() { + let safeArea = view.safeAreaLayoutGuide + view.backgroundColor = BitnagilColor.gray99 + + view.addSubview(mainLabel) + view.addSubview(subLabel) + view.addSubview(recommendedRoutineStackView) + view.addSubview(registerButton) + + mainLabel.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.top.equalTo(safeArea).offset(Layout.mainLabelTopSpacing) + make.height.equalTo(Layout.mainLabelHeight) + } + + subLabel.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.top.equalTo(mainLabel.snp.bottom).offset(Layout.subLabelTopSpacing) + make.height.equalTo(Layout.subLabelHeight) + } + + recommendedRoutineStackView.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.top.equalTo(subLabel.snp.bottom).offset(Layout.routineStackViewTopSpacing) + } + + registerButton.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.bottom.equalTo(safeArea).inset(Layout.registerButtonBottomSpacing) + make.height.equalTo(Layout.registerButtonHeight) + } + } + + public override func bind() { + viewModel.output.recommendedRoutinePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] recommendedRoutines in + self?.updateRecommendedRoutines(routines: recommendedRoutines) + } + .store(in: &cancellables) + + viewModel.output.selectedRoutinePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] selectedRoutines in + self?.updateSelectedRoutines(routines: selectedRoutines) + } + .store(in: &cancellables) + + viewModel.output.nextButtonPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] canRegister in + self?.registerButton.updateButtonState(buttonState: canRegister ? .default : .disabled) + } + .store(in: &cancellables) + } + + private func updateRecommendedRoutines(routines: Set) { + recommendedRoutineStackView.arrangedSubviews.forEach { view in + recommendedRoutineStackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + recommendedRoutines.removeAll() + + for routine in routines { + let routineButton = OnboardingChoiceButton(onboardingChoice: routine) + routineButton.tag = routine.id + + recommendedRoutines[routine.id] = routineButton + recommendedRoutineStackView.addArrangedSubview(routineButton) + routineButton.addAction(UIAction { [weak self] _ in + self?.viewModel.action(input: .selectRoutine(routine: routine)) + }, for: .touchUpInside) + + routineButton.snp.makeConstraints { make in + make.height.equalTo(Layout.routineButtonHeight) + } + } + } + + private func updateSelectedRoutines(routines: Set) { + recommendedRoutines.forEach { routine in + if routines.contains(where: { $0.id == routine.key }) { + routine.value.updateButtonState(isChecked: true) + } else { + routine.value.updateButtonState(isChecked: false) + } + } + } +} From 42528d72fe172423611985ac83a3072c1e107745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8E=E1=85=AC=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8B?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Fri, 11 Jul 2025 20:50:08 +0900 Subject: [PATCH 8/9] =?UTF-8?q?Feat:=20OnboardingViewModel=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#T3-68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/OnboardingViewModel.swift | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift diff --git a/Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift b/Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift new file mode 100644 index 00000000..27b5ebad --- /dev/null +++ b/Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift @@ -0,0 +1,204 @@ +// +// OnboardingViewModel.swift +// Presentation +// +// Created by 최정인 on 7/8/25. +// + +import Combine + +final class OnboardingViewModel: ViewModel { + public enum Input { + case selectOnboardingChoice(selectedChoice: OnboardingChoiceType) + case fetchOnboardingChoice(onboarding: OnboardingType) + case makeOnboardingResult + case fetchRecommendedRoutine + case selectRoutine(routine: RecommendedRoutine) + case registerRecommendedRoutine + } + + public struct Output { + let timeOnboardingChoicePublisher: AnyPublisher + let frequencyOnboardingChoicePublisher: AnyPublisher + let feelingOnboardingChoicePublisher: AnyPublisher, Never> + let outdoorGoalOnboardingChoicePublisher: AnyPublisher + let onboardingResultPublisher: AnyPublisher<[String], Never> + let recommendedRoutinePublisher: AnyPublisher, Never> + let selectedRoutinePublisher: AnyPublisher, Never> + let nextButtonPublisher: AnyPublisher + } + + private(set) var output: Output + private let timeOnboardingChoiceSubject = CurrentValueSubject(nil) + private let frequencyOnboardingChoiceSubject = CurrentValueSubject(nil) + private let feelingOnboardingChoiceSubject = CurrentValueSubject, Never>([]) + private let outdoorGoalOnboardingChoiceSubject = CurrentValueSubject(nil) + private let onboardingResultSubject = CurrentValueSubject<[String], Never>([]) + private let recommendedRoutineSubject = CurrentValueSubject, Never>([]) + private let selectedRoutineSubject = CurrentValueSubject, Never>([]) + private let nextButtonSubject = PassthroughSubject() + + public init() { + self.output = Output( + timeOnboardingChoicePublisher: timeOnboardingChoiceSubject.eraseToAnyPublisher(), + frequencyOnboardingChoicePublisher: frequencyOnboardingChoiceSubject.eraseToAnyPublisher(), + feelingOnboardingChoicePublisher: feelingOnboardingChoiceSubject.eraseToAnyPublisher(), + outdoorGoalOnboardingChoicePublisher: outdoorGoalOnboardingChoiceSubject.eraseToAnyPublisher(), + onboardingResultPublisher: onboardingResultSubject.eraseToAnyPublisher(), + recommendedRoutinePublisher: recommendedRoutineSubject.eraseToAnyPublisher(), + selectedRoutinePublisher: selectedRoutineSubject.eraseToAnyPublisher(), + nextButtonPublisher: nextButtonSubject.eraseToAnyPublisher() + ) + } + + public func action(input: Input) { + switch input { + case .selectOnboardingChoice(let selectedChoice): + selectChoice(choice: selectedChoice) + case .fetchOnboardingChoice(let onboarding): + fetchChoice(onboarding: onboarding) + case .makeOnboardingResult: + makeOnboardingResult() + case .fetchRecommendedRoutine: + fetchRecommendedRoutine() + case .selectRoutine(let routine): + selectRoutine(routine: routine) + case .registerRecommendedRoutine: + registerRecommendedRoutine() + } + } + + // 선택된 온보딩 결과를 가져옵니다. + private func fetchChoice(onboarding: OnboardingType) { + switch onboarding { + case .time: + timeOnboardingChoiceSubject.send(timeOnboardingChoiceSubject.value) + updateNextButtonSubject(choiceSubject: timeOnboardingChoiceSubject) + + case .feeling: + feelingOnboardingChoiceSubject.send(feelingOnboardingChoiceSubject.value) + updateNextButtonSubject() + + case .frequency: + frequencyOnboardingChoiceSubject.send(frequencyOnboardingChoiceSubject.value) + updateNextButtonSubject(choiceSubject: frequencyOnboardingChoiceSubject) + + case .outdoorGoal: + outdoorGoalOnboardingChoiceSubject.send(outdoorGoalOnboardingChoiceSubject.value) + updateNextButtonSubject(choiceSubject: outdoorGoalOnboardingChoiceSubject) + } + } + + // 온보딩 선택지를 선택합니다. + private func selectChoice(choice: OnboardingChoiceType) { + switch choice.onboardingType { + case .time, .frequency, .outdoorGoal: + selectOnlyOneChoice(choice: choice) + case .feeling: + selecteMultipleChoices(choice: choice) + } + } + + // 온보딩 선택지를 선택합니다. (단일 선택 온보딩) + private func selectOnlyOneChoice(choice: OnboardingChoiceType) { + var onboardSubject = CurrentValueSubject(nil) + switch choice.onboardingType { + case .time: + onboardSubject = timeOnboardingChoiceSubject + case .frequency: + onboardSubject = frequencyOnboardingChoiceSubject + case .outdoorGoal: + onboardSubject = outdoorGoalOnboardingChoiceSubject + default: + onboardSubject = outdoorGoalOnboardingChoiceSubject + } + + let currentChoice = onboardSubject.value + onboardSubject.send(nil) + if choice != currentChoice { + onboardSubject.send(choice) + } + updateNextButtonSubject(choiceSubject: onboardSubject) + } + + // 온보딩 선택지를 선택합니다. (중복 선택 온보딩) + private func selecteMultipleChoices(choice: OnboardingChoiceType) { + var choices = feelingOnboardingChoiceSubject.value + if choices.contains(choice) { + choices.remove(choice) + } else { + choices.insert(choice) + } + + feelingOnboardingChoiceSubject.send(choices) + updateNextButtonSubject() + } + + // 다음 버튼 활성화 여부를 결정합니다. (단일 선택 온보딩) + private func updateNextButtonSubject(choiceSubject: CurrentValueSubject) { + if choiceSubject.value != nil { + nextButtonSubject.send(true) + } else { + nextButtonSubject.send(false) + } + } + + // 다음 버튼 활성화 여부를 결정합니다. (중복 선택 온보딩, 추천 루틴 등록) + private func updateNextButtonSubject(for registerButton: Bool = false) { + var result: Bool + if registerButton { + result = !selectedRoutineSubject.value.isEmpty + } else { + result = !feelingOnboardingChoiceSubject.value.isEmpty + } + nextButtonSubject.send(result) + } + + // 온보딩 결과를 만듭니다. + private func makeOnboardingResult() { + let feelingOnboardingChoice = feelingOnboardingChoiceSubject.value + let feelingResult = feelingOnboardingChoice.compactMap { $0.resultTitle }.joined(separator: ", ") + + guard + let timeOnboardingChoice = timeOnboardingChoiceSubject.value, + let outdoorGoalOnboardingChoice = outdoorGoalOnboardingChoiceSubject.value, + let timeResult = timeOnboardingChoice.resultTitle, + let outdoorGoalResult = outdoorGoalOnboardingChoice.resultTitle + else { + return + } + + let result = [timeResult, feelingResult, outdoorGoalResult] + onboardingResultSubject.send(result) + } + + // 온보딩 결과를 바탕으로 추천 루틴을 불러옵니다. + private func fetchRecommendedRoutine() { + recommendedRoutineSubject.send([]) + // TODO: 서버 API 만들어진 후 UseCase와 연동하는 작업이 필요합니다. + let recommendedRoutines: [RecommendedRoutine] = [ + RecommendedRoutine(id: 1, mainTitle: "루틴명", subTitle: "세부 루틴 한 줄 설명"), + RecommendedRoutine(id: 2, mainTitle: "루틴명", subTitle: "세부 루틴 한 줄 설명"), + RecommendedRoutine(id: 3, mainTitle: "루틴명", subTitle: "세부 루틴 한 줄 설명") + ] + recommendedRoutineSubject.send(Set(recommendedRoutines)) + } + + // 추천 루틴을 선택합니다. + private func selectRoutine(routine: RecommendedRoutine) { + var selectedRoutines = selectedRoutineSubject.value + if selectedRoutines.contains(routine) { + selectedRoutines.remove(routine) + } else { + selectedRoutines.insert(routine) + } + + selectedRoutineSubject.send(selectedRoutines) + updateNextButtonSubject(for: true) + } + + // 추천 루틴을 등록합니다. + private func registerRecommendedRoutine() { + // TODO: 서버 API 만들어진 후 UseCase와 연동하는 작업이 필요합니다. + } +} From 831e2f4730ba804bd7bc9201e67581cebb738185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8E=E1=85=AC=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8B?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Fri, 11 Jul 2025 20:50:48 +0900 Subject: [PATCH 9/9] =?UTF-8?q?Feat:=20OnboardingViewModel=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=A1=B0=EB=A6=BD=20=EB=B0=8F=20=EC=9D=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=95=BD=EA=B4=80=20=ED=99=94=EB=A9=B4=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=98=A8=EB=B3=B4=EB=94=A9=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=84=98=EC=96=B4=EA=B0=80=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20(#T3-68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/Extensions/UIViewController+.swift | 32 ++++++++++++++++++- .../PresentationDependencyAssembler.swift | 4 +++ .../Login/View/TermsAgreementView.swift | 9 ++++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/Projects/Presentation/Sources/Common/Extensions/UIViewController+.swift b/Projects/Presentation/Sources/Common/Extensions/UIViewController+.swift index 0fd39474..e15844e2 100644 --- a/Projects/Presentation/Sources/Common/Extensions/UIViewController+.swift +++ b/Projects/Presentation/Sources/Common/Extensions/UIViewController+.swift @@ -23,9 +23,15 @@ extension UIViewController { navigationController?.setNavigationBarHidden(false, animated: false) configureDefaultBackButton() configureProgressNavigationBar(step: step, stepCount: stepCount) + + case .withPrograssBarWithCustomBackButton(let step, let stepCount): + navigationController?.setNavigationBarHidden(false, animated: false) + configureCustomBackButton() + configureProgressNavigationBar(step: step, stepCount: stepCount) + } } - func configureDefaultBackButton() { + private func configureDefaultBackButton() { let backButton = UIBarButtonItem( image: UIImage(systemName: "chevron.left"), style: .plain, @@ -35,6 +41,16 @@ extension UIViewController { navigationItem.leftBarButtonItem = backButton } + private func configureCustomBackButton() { + let backButton = UIBarButtonItem( + image: UIImage(systemName: "chevron.left"), + style: .plain, + target: self, + action: #selector(popTwoViewControllers)) + backButton.tintColor = .black + navigationItem.leftBarButtonItem = backButton + } + private func configureProgressNavigationBar(step: Int, stepCount: Int) { self.title = "" let progressView = ProgressBarView(step: step, stepCount: stepCount) @@ -44,10 +60,24 @@ extension UIViewController { @objc private func popViewController() { navigationController?.popViewController(animated: true) } + + @objc private func popTwoViewControllers() { + guard let navigationController = navigationController else { return } + let viewControllers = navigationController.viewControllers + + guard viewControllers.count >= 3 else { + navigationController.popViewController(animated: true) + return + } + + let targetViewController = viewControllers[viewControllers.count - 3] + navigationController.popToViewController(targetViewController, animated: true) + } } enum NavigationBarStyle { case hidden case withBackButton(title: String) case withPrograssBar(step: Int, stepCount: Int) + case withPrograssBarWithCustomBackButton(step: Int, stepCount: Int) } diff --git a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift index 9d7cad8e..6cc4b314 100644 --- a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift +++ b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift @@ -23,6 +23,10 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol { return HomeViewModel() } + DIContainer.shared.register(type: OnboardingViewModel.self) { _ in + return OnboardingViewModel() + } + DIContainer.shared.register(type: LoginViewModel.self) { container in guard let loginUseCase = container.resolve(type: LoginUseCaseProtocol.self) else { return } diff --git a/Projects/Presentation/Sources/Login/View/TermsAgreementView.swift b/Projects/Presentation/Sources/Login/View/TermsAgreementView.swift index f1cc71b3..243e6b1a 100644 --- a/Projects/Presentation/Sources/Login/View/TermsAgreementView.swift +++ b/Projects/Presentation/Sources/Login/View/TermsAgreementView.swift @@ -127,10 +127,15 @@ public final class TermsAgreementView: BaseViewController { viewModel.output.agreementResultPublisher .receive(on: DispatchQueue.main) - .sink { agreementResult in + .sink { [weak self] agreementResult in + guard let self else { return } if agreementResult { - // TODO: 약관 동의 성공 시 온보딩 화면으로 이동해야 합니다. BitnagilLogger.log(logType: .debug, message: "약관 동의 성공") + guard let onboardingViewModel = DIContainer.shared.resolve(type: OnboardingViewModel.self) else { + fatalError("onboardingViewModel 의존성이 등록되지 않았습니다.") + } + let onboardingView = OnboardingView(viewModel: onboardingViewModel, onboarding: .time) + self.navigationController?.pushViewController(onboardingView, animated: true) } else { // TODO: 약관 동의 실패 시, 에러 처리를 해야 합니다. BitnagilLogger.log(logType: .error, message: "약관 동의 실패")