diff --git a/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/asterisk_icon.imageset/Contents.json similarity index 65% rename from Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/Contents.json rename to Projects/Presentation/Resources/Images.xcassets/asterisk_icon.imageset/Contents.json index 30cca47b..01a8db69 100644 --- a/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/Contents.json +++ b/Projects/Presentation/Resources/Images.xcassets/asterisk_icon.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "chevron_right_icon@1x.png", + "filename" : "asterisk_icon@1x.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "chevron_right_icon@2x.png", + "filename" : "asterisk_icon@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "chevron_right_icon@3x.png", + "filename" : "asterisk_icon@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/Projects/Presentation/Resources/Images.xcassets/asterisk_icon.imageset/asterisk_icon@1x.png b/Projects/Presentation/Resources/Images.xcassets/asterisk_icon.imageset/asterisk_icon@1x.png new file mode 100644 index 00000000..c5e5addc Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/asterisk_icon.imageset/asterisk_icon@1x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/asterisk_icon.imageset/asterisk_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/asterisk_icon.imageset/asterisk_icon@2x.png new file mode 100644 index 00000000..1e2f0c23 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/asterisk_icon.imageset/asterisk_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/asterisk_icon.imageset/asterisk_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/asterisk_icon.imageset/asterisk_icon@3x.png new file mode 100644 index 00000000..e56e82db Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/asterisk_icon.imageset/asterisk_icon@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/checked_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/checked_icon.imageset/Contents.json new file mode 100644 index 00000000..c2a45ed9 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/checked_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "checked_icon@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "checked_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "checked_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/checked_icon.imageset/checked_icon@1x.png b/Projects/Presentation/Resources/Images.xcassets/checked_icon.imageset/checked_icon@1x.png new file mode 100644 index 00000000..50f4bf9b Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/checked_icon.imageset/checked_icon@1x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/checked_icon.imageset/checked_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/checked_icon.imageset/checked_icon@2x.png new file mode 100644 index 00000000..d69da674 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/checked_icon.imageset/checked_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/checked_icon.imageset/checked_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/checked_icon.imageset/checked_icon@3x.png new file mode 100644 index 00000000..d43e80e0 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/checked_icon.imageset/checked_icon@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@1x.png b/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@1x.png deleted file mode 100644 index 5e32064b..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@1x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@2x.png deleted file mode 100644 index 4d4a4d74..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@2x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@3x.png deleted file mode 100644 index 4b2d170a..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/chevron_right_icon.imageset/chevron_right_icon@3x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/delete_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/delete_icon.imageset/Contents.json new file mode 100644 index 00000000..922398e8 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/delete_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "delete_icon@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "delete_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "delete_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/delete_icon.imageset/delete_icon@1x.png b/Projects/Presentation/Resources/Images.xcassets/delete_icon.imageset/delete_icon@1x.png new file mode 100644 index 00000000..ea7e8fdf Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/delete_icon.imageset/delete_icon@1x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/delete_icon.imageset/delete_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/delete_icon.imageset/delete_icon@2x.png new file mode 100644 index 00000000..631d0f9c Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/delete_icon.imageset/delete_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/delete_icon.imageset/delete_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/delete_icon.imageset/delete_icon@3x.png new file mode 100644 index 00000000..bbf7736b Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/delete_icon.imageset/delete_icon@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/routine_creation_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/routine_creation_icon.imageset/Contents.json new file mode 100644 index 00000000..7b80bf67 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/routine_creation_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "routine_creation_icon@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "routine_creation_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "routine_creation_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/routine_creation_icon.imageset/routine_creation_icon@1x.png b/Projects/Presentation/Resources/Images.xcassets/routine_creation_icon.imageset/routine_creation_icon@1x.png new file mode 100644 index 00000000..5cab13c8 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/routine_creation_icon.imageset/routine_creation_icon@1x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/routine_creation_icon.imageset/routine_creation_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/routine_creation_icon.imageset/routine_creation_icon@2x.png new file mode 100644 index 00000000..026401b9 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/routine_creation_icon.imageset/routine_creation_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/routine_creation_icon.imageset/routine_creation_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/routine_creation_icon.imageset/routine_creation_icon@3x.png new file mode 100644 index 00000000..c65eb725 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/routine_creation_icon.imageset/routine_creation_icon@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/unchecked_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/unchecked_icon.imageset/Contents.json new file mode 100644 index 00000000..6ba4b614 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/unchecked_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "unchecked_icon@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "unchecked_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "unchecked_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/unchecked_icon.imageset/unchecked_icon@1x.png b/Projects/Presentation/Resources/Images.xcassets/unchecked_icon.imageset/unchecked_icon@1x.png new file mode 100644 index 00000000..a3063517 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/unchecked_icon.imageset/unchecked_icon@1x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/unchecked_icon.imageset/unchecked_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/unchecked_icon.imageset/unchecked_icon@2x.png new file mode 100644 index 00000000..acb6c630 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/unchecked_icon.imageset/unchecked_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/unchecked_icon.imageset/unchecked_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/unchecked_icon.imageset/unchecked_icon@3x.png new file mode 100644 index 00000000..752adaf0 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/unchecked_icon.imageset/unchecked_icon@3x.png differ diff --git a/Projects/Presentation/Sources/Common/Component/TooltipView.swift b/Projects/Presentation/Sources/Common/Component/TooltipView.swift new file mode 100644 index 00000000..74cb870c --- /dev/null +++ b/Projects/Presentation/Sources/Common/Component/TooltipView.swift @@ -0,0 +1,132 @@ +// +// ToolTipView.swift +// Presentation +// +// Created by 이동현 on 7/20/25. +// + +import SnapKit +import UIKit + +final class TooltipView: UIView { + enum TooltipTailPosition { + case center + case offsetFromLeading(CGFloat) + } + + private enum Layout { + static let backgroundViewBottomInset: CGFloat = 8 + static let messageLabelInset: CGFloat = 10 + static let tailWidth: CGFloat = 14 + static let tailHeight: CGFloat = 8 + static let cornerRadius: CGFloat = 8 + } + + private let backgroundView = UIView() + private let messageLabel = UILabel() + private let tailLayer = CAShapeLayer() + private let tailPosition: TooltipTailPosition + + init(tailPosition: TooltipTailPosition) { + self.tailPosition = tailPosition + super.init(frame: .zero) + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + generateTail() + } + + private func configureAttribute() { + backgroundView.backgroundColor = BitnagilColor.navy400 + backgroundView.layer.cornerRadius = Layout.cornerRadius + messageLabel.textColor = .white + messageLabel.font = BitnagilFont.init(style: .caption1, weight: .medium).font + } + + private func configureLayout() { + addSubview(backgroundView) + backgroundView.addSubview(messageLabel) + + backgroundView.snp.makeConstraints { make in + make.top.horizontalEdges.equalToSuperview() + make.bottom.equalToSuperview().inset(Layout.backgroundViewBottomInset) + } + + messageLabel.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(Layout.messageLabelInset) + } + } + + func configure(message: String) { + messageLabel.text = message + } + + private func generateTail() { + let startX: CGFloat + let width = Layout.tailWidth + let height = Layout.tailHeight + let y = bounds.height - height + + switch tailPosition { + case .center: + startX = (backgroundView.bounds.width - width) / 2 + case .offsetFromLeading(let offset): + startX = offset + } + + let path = UIBezierPath() + path.move(to: CGPoint(x: startX, y: y)) + path.addQuadCurve( + to: CGPoint(x: startX + width, y: y), + controlPoint: CGPoint(x: startX + width / 2, y: y + height * 1.5)) + path.addLine(to: CGPoint(x: startX + width, y: y)) + path.close() + + tailLayer.path = path.cgPath + tailLayer.fillColor = backgroundView.backgroundColor?.cgColor + + if tailLayer.superlayer == nil { + layer.addSublayer(tailLayer) + } + } + + func showTooltip() { + self.alpha = 0 + self.transform = CGAffineTransform(scaleX: 0.7, y: 0.7) + self.isHidden = false + + UIView.animate( + withDuration: 0.2, + delay: 0, + usingSpringWithDamping: 1, + initialSpringVelocity: 1, + options: [.curveEaseOut], + animations: { + self.alpha = 1 + self.transform = .identity + }, + completion: nil) + } + + func hideTooltip(completion: (() -> Void)? = nil) { + UIView.animate( + withDuration: 0.15, + delay: 0, + options: [.curveEaseIn], + animations: { + self.alpha = 0 + self.transform = CGAffineTransform(scaleX: 0.7, y: 0.7) + }) { _ in + self.isHidden = true + self.transform = .identity + completion?() + } + } +} diff --git a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift index 4b466fa5..3930cbfe 100644 --- a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift +++ b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift @@ -37,9 +37,15 @@ enum BitnagilIcon { static let mypageFillIcon = UIImage(named: "mypage_fill_icon", in: bundle, with: nil) static let mypageEmptyIcon = UIImage(named: "mypage_empty_icon", in: bundle, with: nil) + // MARK: - Mypage Icons static let settingIcon = UIImage(named: "setting_icon", in: bundle, with: nil) - static let chevronRightIcon = UIImage(named: "chevron_right_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) + static let uncheckedIcon = UIImage(named: "unchecked_icon", in: bundle, with: nil) + static let checkedIcon = UIImage(named: "checked_icon", in: bundle, with: nil) + static let routineCreationIcon = UIImage(named: "routine_creation_icon", in: bundle, with: nil) } enum Direction { diff --git a/Projects/Presentation/Sources/MyPage/View/Component/MypageTableViewCell.swift b/Projects/Presentation/Sources/MyPage/View/Component/MypageTableViewCell.swift index 54046b63..af7160b7 100644 --- a/Projects/Presentation/Sources/MyPage/View/Component/MypageTableViewCell.swift +++ b/Projects/Presentation/Sources/MyPage/View/Component/MypageTableViewCell.swift @@ -12,8 +12,8 @@ final class MypageTableViewCell: UITableViewCell { private enum Layout { static let titleLableLeadingSpacing: CGFloat = 20 static let titleLableTrailingSpacing: CGFloat = 8 - static let chevronImageViewTrailingSpacing: CGFloat = 6 - static let chevronImageViewSize: CGFloat = 36 + static let chevronImageViewTrailingSpacing: CGFloat = 16 + static let chevronImageViewSize: CGFloat = 16 } private let titleLabel = UILabel() @@ -36,7 +36,10 @@ final class MypageTableViewCell: UITableViewCell { private func configureAttribute() { titleLabel.font = BitnagilFont(style: .body1, weight: .regular).font - chevronImageView.image = BitnagilIcon.chevronRightIcon + chevronImageView.tintColor = .black + chevronImageView.image = BitnagilIcon + .chevronIcon(direction: .right)? + .withRenderingMode(.alwaysTemplate) } private func configureLayout() { diff --git a/Projects/Presentation/Sources/RoutineCreation/Model/Week.swift b/Projects/Presentation/Sources/RoutineCreation/Model/Week.swift new file mode 100644 index 00000000..ab9dd8d6 --- /dev/null +++ b/Projects/Presentation/Sources/RoutineCreation/Model/Week.swift @@ -0,0 +1,35 @@ +// +// Week.swift +// Presentation +// +// Created by 이동현 on 7/21/25. +// + +enum Week: Int, CaseIterable { + case monday + case tuesday + case wednesday + case thursday + case friday + case saturday + case sunday + + var koreanValue: String { + switch self { + case .monday: + "월" + case .tuesday: + "화" + case .wednesday: + "수" + case .thursday: + "목" + case .friday: + "금" + case .saturday: + "토" + case .sunday: + "일" + } + } +} diff --git a/Projects/Presentation/Sources/RoutineCreation/View/Component/RoutineCreationInputView.swift b/Projects/Presentation/Sources/RoutineCreation/View/Component/RoutineCreationInputView.swift new file mode 100644 index 00000000..74c9a418 --- /dev/null +++ b/Projects/Presentation/Sources/RoutineCreation/View/Component/RoutineCreationInputView.swift @@ -0,0 +1,105 @@ +// +// RoutineCreationInputView.swift +// Presentation +// +// Created by 이동현 on 7/20/25. +// + +import SnapKit +import UIKit + +/// 루틴 등록 화면 텍스트 입력 delegate +protocol RoutineCreationInputViewDelegate: AnyObject { + /// 등록한 view를 삭제합니다. + func routineCreationInputViewDidTapDeleteButton(_ sender: RoutineCreationInputView) + + /// 입력 중인 text를 반환합니다. + func routineCreationInputView(_ sender: RoutineCreationInputView, didChangeText text: String) +} + +final class RoutineCreationInputView: UIView { + private enum Layout { + static let textFieldLeadingOffset: CGFloat = 24 + static let textFieldTrailingInset: CGFloat = 51 + static let textFieldHeight: CGFloat = 20 + static let deleteButtonSize: CGFloat = 24 + static let deleteButtonTrailingInset: CGFloat = 8 + } + + private let textField = UITextField() + private let deleteButton = UIButton() + weak var delegate: RoutineCreationInputViewDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + configureAttribute() + configureLayout() + + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + backgroundColor = BitnagilColor.gray99 + layer.cornerRadius = 12 + + textField.delegate = self + textField.textColor = .black + textField.font = BitnagilFont.init(style: .body2, weight: .semiBold).font + + deleteButton.setImage(BitnagilIcon.deleteIcon, for: .normal) + deleteButton.addAction( + UIAction { [weak self] _ in + guard let self else { return } + self.delegate?.routineCreationInputViewDidTapDeleteButton(self) + }, for: .touchUpInside) + } + + private func configureLayout() { + addSubview(textField) + addSubview(deleteButton) + + textField.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Layout.textFieldLeadingOffset) + make.trailing.equalToSuperview().inset(Layout.textFieldTrailingInset) + make.centerY.equalToSuperview() + make.height.equalTo(Layout.textFieldHeight) + } + + deleteButton.snp.makeConstraints { make in + make.size.equalTo(Layout.deleteButtonSize) + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset(Layout.deleteButtonTrailingInset) + } + } + + func configure(canDelete: Bool, placeholder: String?) { + deleteButton.isHidden = !canDelete + + guard + let placeholder, + let placeholderColor = BitnagilColor.gray90 + else { return } + + textField.attributedPlaceholder = NSAttributedString( + string: placeholder, + attributes: [ + .foregroundColor: placeholderColor, + .font: BitnagilFont.init(style: .body2, weight: .semiBold).font]) + } + + func configure(title: String) { + textField.text = title + } +} + +extension RoutineCreationInputView: UITextFieldDelegate { + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if let text = textField.text { + delegate?.routineCreationInputView(self, didChangeText: text) + } + return true + } +} diff --git a/Projects/Presentation/Sources/RoutineCreation/View/Component/RoutineTimePickerButton.swift b/Projects/Presentation/Sources/RoutineCreation/View/Component/RoutineTimePickerButton.swift new file mode 100644 index 00000000..dd546292 --- /dev/null +++ b/Projects/Presentation/Sources/RoutineCreation/View/Component/RoutineTimePickerButton.swift @@ -0,0 +1,67 @@ +// +// RoutineTimePickerButton.swift +// Presentation +// +// Created by 이동현 on 7/27/25. +// + +import SnapKit +import UIKit + +final class RoutineTimePickerButton: UIButton { + private enum Layout { + static let chevronDownImageViewTrailingSpacing: CGFloat = 19 + static let chevronDownImageViewSize: CGFloat = 20 + static let labelLeadingSpacing: CGFloat = 24 + } + + private let chevronDownImageView = UIImageView() + private let label = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + layer.borderWidth = 1 + layer.borderColor = BitnagilColor.navy100?.cgColor + layer.cornerRadius = 12 + backgroundColor = .white + + chevronDownImageView.contentMode = .scaleAspectFit + chevronDownImageView.tintColor = BitnagilColor.navy400 + chevronDownImageView.image = BitnagilIcon + .chevronIcon(direction: .down)? + .withRenderingMode(.alwaysTemplate) + + label.text = "시간 선택" + label.textColor = BitnagilColor.gray40 + label.font = BitnagilFont.init(style: .body2, weight: .medium).font + } + + private func configureLayout() { + addSubview(chevronDownImageView) + addSubview(label) + + chevronDownImageView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(Layout.chevronDownImageViewTrailingSpacing) + make.centerY.equalToSuperview() + make.size.equalTo(Layout.chevronDownImageViewSize) + } + + label.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Layout.labelLeadingSpacing) + make.centerY.equalToSuperview() + } + } + + func configure(title: String) { + label.text = title + } +} diff --git a/Projects/Presentation/Sources/RoutineCreation/View/Component/SubroutineAdditionButton.swift b/Projects/Presentation/Sources/RoutineCreation/View/Component/SubroutineAdditionButton.swift new file mode 100644 index 00000000..d5aaeaea --- /dev/null +++ b/Projects/Presentation/Sources/RoutineCreation/View/Component/SubroutineAdditionButton.swift @@ -0,0 +1,63 @@ +// +// DetailRoutineAdditionButton.swift +// Presentation +// +// Created by 이동현 on 7/21/25. +// + +import SnapKit +import UIKit + +final class SubroutineAdditionButton: UIButton { + private enum Layout { + static let plusImageViewLeadingSpacing: CGFloat = 24 + static let plusImageViewSize: CGFloat = 20 + static let labelLeadingSpacing: CGFloat = 10 + } + + private let plusImageView = UIImageView() + private let label = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + layer.borderWidth = 1 + layer.borderColor = BitnagilColor.navy100?.cgColor + layer.cornerRadius = 12 + backgroundColor = .white + + plusImageView.contentMode = .scaleAspectFit + plusImageView.tintColor = BitnagilColor.navy400 + plusImageView.image = BitnagilIcon + .routineCreationIcon? + .withRenderingMode(.alwaysTemplate) + + label.text = "세부루틴 추가" + label.textColor = BitnagilColor.gray40 + label.font = BitnagilFont.init(style: .body2, weight: .medium).font + } + + private func configureLayout() { + addSubview(plusImageView) + addSubview(label) + + plusImageView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Layout.plusImageViewLeadingSpacing) + make.centerY.equalToSuperview() + make.size.equalTo(Layout.plusImageViewSize) + } + + label.snp.makeConstraints { make in + make.leading.equalTo(plusImageView.snp.trailing).offset(Layout.labelLeadingSpacing) + make.centerY.equalToSuperview() + } + } +} diff --git a/Projects/Presentation/Sources/RoutineCreation/View/RoutineCreationView.swift b/Projects/Presentation/Sources/RoutineCreation/View/RoutineCreationView.swift new file mode 100644 index 00000000..78582920 --- /dev/null +++ b/Projects/Presentation/Sources/RoutineCreation/View/RoutineCreationView.swift @@ -0,0 +1,609 @@ +// +// RoutineCreationViewController.swift +// Presentation +// +// Created by 이동현 on 7/20/25. +// + +import Combine +import SnapKit +import UIKit + +final class RoutineCreationView: BaseViewController { + private enum Layout { + static let horizontalInset: CGFloat = 20 + static let nameTitleLabelTopSpacing: CGFloat = 32 + static let titleLabelHeight: CGFloat = 24 + static let titleLabelTopSpacing: CGFloat = 40 + static let titleLabelBottomSpacing: CGFloat = 14 + static let titleAsteriskTopSpacing: CGFloat = 2 + static let titleAsteriskLeadingSpacing: CGFloat = 2 + static let titleAsteriskHeight: CGFloat = 24 + static let titleAsteriskWidth: CGFloat = 9 + static let inputViewHeight: CGFloat = 52 + static let subroutineStackViewHeight: CGFloat = 0 + static let subroutineStackViewSpacing: CGFloat = 10 + static let warningLabelTopSpacing: CGFloat = 8 + static let warningLabelHeight: CGFloat = 18 + static let repeatButtonHeight: CGFloat = 52 + static let repeatWeekDayButtonLeadingSpacing: CGFloat = 13 + static let infoButtonSize: CGFloat = 16 + static let infoButtonLeadingSpacing: CGFloat = 6 + static let tooltipViewHeight: CGFloat = 47 + static let tooltipViewLeadingSpacing: CGFloat = 22 + static let tooltipViewBottomSpacing: CGFloat = 8 + static let tooltipViewTailLeadingSpacing: CGFloat = 16 + static let detailTooltipWidth: CGFloat = 209 + static let repeatTooltipWidth: CGFloat = 262 + static let weekDayStackViewTopSpacing: CGFloat = 16 + static let weekDayStackViewSpacing: CGFloat = 10 + static let weekDayStackViewHeight: CGFloat = 48 + static let allDayButtonSize: CGFloat = 20 + static let allDayButtonTrailingSpacing: CGFloat = 6 + static let allDayLabelWidth: CGFloat = 55 + static let registerButtonTopSpacing: CGFloat = 54 + static let registerButtonHeight: CGFloat = 54 + static let registerButtonBottomSpacing: CGFloat = 14 + } + + private let scrollView = UIScrollView() + private let contentView = UIView() + + private let nameTitleLabel = UILabel() + private let nameAsterisk = UIImageView() + private let nameInputView = RoutineCreationInputView() + + private let detailTitleLabel = UILabel() + private let detailInfoButton = UIButton() + private let subroutineAdditionButton = SubroutineAdditionButton() + private let subroutineStackView = UIStackView() + private let detailWarningLabel = UILabel() + private let detailToolTipView = TooltipView(tailPosition: .offsetFromLeading(Layout.tooltipViewTailLeadingSpacing)) + + private let repeatTitleLabel = UILabel() + private let repeatInfoButton = UIButton() + private let repeatToolTipView = TooltipView(tailPosition: .offsetFromLeading(Layout.tooltipViewTailLeadingSpacing)) + private let repeatDailyButton = UIButton() + private let repeatWeekButton = UIButton() + + private let startTimeTitleLabel = UILabel() + private let startTimeAsterisk = UIImageView() + private let timePickerButton = RoutineTimePickerButton() + private let allDayButton = UIButton() + private let allDayLabel = UILabel() + private let weekdaysStackView = UIStackView() + + private let registerButton = UIButton() + + private var repeatRoutineTitleTopConstraint: Constraint? + private var startTimeTitleTopConstraint: Constraint? + + private var cancellables = Set() + + override init(viewModel: RoutineCreationViewModel) { + super.init(viewModel: viewModel) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureNavigationBar(navigationStyle: .withBackButton(title: "루틴 등록")) + } + + override func configureAttribute() { + configureSubroutineStackView() + configureWeekdaysStackView() + configureButtonActions() + + let titleLabels = [ + nameTitleLabel, + detailTitleLabel, + repeatTitleLabel, + startTimeTitleLabel] + let infoButtons = [detailInfoButton, repeatInfoButton] + let repeatButtons = [repeatDailyButton, repeatWeekButton] + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + + view.addGestureRecognizer(tapGesture) + view.backgroundColor = .white + tapGesture.cancelsTouchesInView = false + + titleLabels.forEach { + $0.textColor = .black + $0.font = BitnagilFont.init(style: .body1, weight: .semiBold).font + } + + nameAsterisk.image = BitnagilIcon.asteriskIcon + startTimeAsterisk.image = BitnagilIcon.asteriskIcon + nameAsterisk.contentMode = .scaleAspectFit + startTimeAsterisk.contentMode = .scaleAspectFit + + infoButtons.forEach { + $0.setImage(BitnagilIcon.informationIcon, for: .normal) + } + + repeatButtons.forEach { + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 12 + $0.titleLabel?.font = BitnagilFont.init(style: .body2, weight: .semiBold).font + } + + nameTitleLabel.text = "루틴 이름" + detailTitleLabel.text = "세부 루틴" + repeatTitleLabel.text = "루틴 반복" + startTimeTitleLabel.text = "시작 시간" + + nameInputView.delegate = self + nameInputView.configure( + canDelete: false, + placeholder: "ex) \(RoutineCreationViewModel.RoutineExample.wakeUp.rawValue)") + + allDayButton.setImage(BitnagilIcon.uncheckedIcon, for: .normal) + + detailWarningLabel.text = "* 세부 루틴은 최대 3개까지 입력이 가능해요" + detailWarningLabel.textColor = BitnagilColor.error + detailWarningLabel.font = BitnagilFont(style: .caption1, weight: .medium).font + + detailToolTipView.configure(message: "어려운 루틴이라면 단계별로 나눠보세요!") + repeatToolTipView.configure(message: "선택하지 않을 경우, 당일 루틴으로만 자동 설정돼요.") + detailToolTipView.isHidden = true + repeatToolTipView.isHidden = true + + repeatDailyButton.setTitle("매일", for: .normal) + repeatWeekButton.setTitle("요일 선택", for: .normal) + + allDayLabel.text = "하루 종일" + allDayLabel.textColor = BitnagilColor.navy400 + allDayLabel.font = BitnagilFont.init(style: .body2, weight: .medium).font + + registerButton.backgroundColor = BitnagilColor.navy50 + registerButton.setTitleColor(.white, for: .normal) + registerButton.setTitleColor(BitnagilColor.gray70, for: .disabled) + + registerButton.layer.cornerRadius = 12 + registerButton.layer.masksToBounds = true + registerButton.setTitle("등록하기", for: .normal) + registerButton.isEnabled = false + registerButton.titleLabel?.font = BitnagilFont.init(style: .body1, weight: .semiBold).font + } + + override func configureLayout() { + let safeArea = view.safeAreaLayoutGuide + + view.addSubview(scrollView) + scrollView.addSubview(contentView) + + contentView.addSubview(nameTitleLabel) + contentView.addSubview(nameAsterisk) + contentView.addSubview(nameInputView) + + contentView.addSubview(detailTitleLabel) + contentView.addSubview(detailInfoButton) + contentView.addSubview(detailToolTipView) + contentView.addSubview(subroutineAdditionButton) + contentView.addSubview(subroutineStackView) + contentView.addSubview(detailWarningLabel) + + contentView.addSubview(repeatTitleLabel) + contentView.addSubview(repeatToolTipView) + contentView.addSubview(repeatInfoButton) + contentView.addSubview(repeatDailyButton) + contentView.addSubview(repeatWeekButton) + contentView.addSubview(weekdaysStackView) + + contentView.addSubview(startTimeTitleLabel) + contentView.addSubview(startTimeAsterisk) + contentView.addSubview(allDayButton) + contentView.addSubview(allDayLabel) + contentView.addSubview(timePickerButton) + + contentView.addSubview(registerButton) + + scrollView.snp.makeConstraints { make in + make.edges.equalTo(safeArea) + } + contentView.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide) + make.width.equalTo(scrollView.frameLayoutGuide) + make.height.equalTo(scrollView.frameLayoutGuide).priority(.low) + } + + nameTitleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Layout.nameTitleLabelTopSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalInset) + make.height.equalTo(Layout.titleLabelHeight) + } + + nameAsterisk.snp.makeConstraints { make in + make.top.equalTo(nameTitleLabel).offset(-Layout.titleAsteriskTopSpacing) + make.leading.equalTo(nameTitleLabel.snp.trailing).offset(Layout.titleAsteriskLeadingSpacing) + make.height.equalTo(Layout.titleAsteriskHeight) + make.width.equalTo(Layout.titleAsteriskWidth) + } + + nameInputView.snp.makeConstraints { make in + make.top.equalTo(nameTitleLabel.snp.bottom).offset(Layout.titleLabelBottomSpacing) + make.horizontalEdges.equalToSuperview().inset(Layout.horizontalInset) + make.height.equalTo(Layout.inputViewHeight) + } + + detailTitleLabel.snp.makeConstraints { make in + make.top.equalTo(nameInputView.snp.bottom).offset(Layout.titleLabelTopSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalInset) + make.height.equalTo(Layout.titleLabelHeight) + } + + detailInfoButton.snp.makeConstraints { make in + make.leading.equalTo(detailTitleLabel.snp.trailing).offset(Layout.infoButtonLeadingSpacing) + make.size.equalTo(Layout.infoButtonSize) + make.centerY.equalTo(detailTitleLabel) + } + + detailToolTipView.snp.makeConstraints { make in + make.leading.equalTo(detailInfoButton.snp.centerX).offset(-Layout.tooltipViewLeadingSpacing) + make.bottom.equalTo(detailInfoButton.snp.top).offset(-Layout.tooltipViewBottomSpacing) + make.width.equalTo(Layout.detailTooltipWidth) + make.height.equalTo(Layout.tooltipViewHeight) + } + + subroutineAdditionButton.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Layout.horizontalInset) + make.top.equalTo(detailTitleLabel.snp.bottom).offset(Layout.titleLabelBottomSpacing) + make.height.equalTo(Layout.inputViewHeight) + } + + subroutineStackView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Layout.horizontalInset) + make.top.equalTo(subroutineAdditionButton.snp.bottom).offset(Layout.subroutineStackViewSpacing) + make.height.equalTo(Layout.subroutineStackViewHeight).priority(.medium) + } + + detailWarningLabel.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Layout.horizontalInset) + make.top.equalTo(subroutineStackView.snp.bottom).offset(Layout.warningLabelTopSpacing) + make.height.equalTo(Layout.warningLabelHeight) + } + + repeatTitleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Layout.horizontalInset) + make.height.equalTo(Layout.titleLabelHeight) + repeatRoutineTitleTopConstraint = make.top + .equalTo(subroutineStackView.snp.bottom) + .offset(Layout.titleLabelTopSpacing) + .constraint + } + + repeatInfoButton.snp.makeConstraints { make in + make.leading.equalTo(repeatTitleLabel.snp.trailing).offset(Layout.infoButtonLeadingSpacing) + make.size.equalTo(Layout.infoButtonSize) + make.centerY.equalTo(repeatTitleLabel) + } + + repeatToolTipView.snp.makeConstraints { make in + make.leading.equalTo(repeatInfoButton.snp.centerX).offset(-Layout.tooltipViewLeadingSpacing) + make.bottom.equalTo(repeatInfoButton.snp.top).offset(-Layout.tooltipViewBottomSpacing) + make.width.equalTo(Layout.repeatTooltipWidth) + make.height.equalTo(Layout.tooltipViewHeight) + } + + repeatDailyButton.snp.makeConstraints { make in + make.top.equalTo(repeatTitleLabel.snp.bottom).offset(Layout.titleLabelBottomSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalInset) + make.height.equalTo(Layout.repeatButtonHeight) + } + + repeatWeekButton.snp.makeConstraints { make in + make.top.equalTo(repeatTitleLabel.snp.bottom).offset(Layout.titleLabelBottomSpacing) + make.leading.equalTo(repeatDailyButton.snp.trailing).offset(Layout.repeatWeekDayButtonLeadingSpacing) + make.trailing.equalToSuperview().inset(Layout.horizontalInset) + make.height.equalTo(Layout.repeatButtonHeight) + make.width.equalTo(repeatDailyButton) + } + + weekdaysStackView.snp.makeConstraints { make in + make.top.equalTo(repeatDailyButton.snp.bottom).offset(Layout.weekDayStackViewTopSpacing) + make.horizontalEdges.equalToSuperview().inset(Layout.horizontalInset) + make.height.equalTo(Layout.weekDayStackViewHeight) + } + + startTimeTitleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(Layout.horizontalInset) + make.height.equalTo(Layout.titleLabelHeight) + startTimeTitleTopConstraint = make.top + .equalTo(repeatDailyButton.snp.bottom) + .offset(Layout.titleLabelTopSpacing) + .constraint + } + + startTimeAsterisk.snp.makeConstraints { make in + make.top.equalTo(startTimeTitleLabel).offset(-Layout.titleAsteriskTopSpacing) + make.leading.equalTo(startTimeTitleLabel.snp.trailing).offset(Layout.titleAsteriskLeadingSpacing) + make.height.equalTo(Layout.titleAsteriskHeight) + make.width.equalTo(Layout.titleAsteriskWidth) + } + + allDayLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(Layout.horizontalInset) + make.width.equalTo(Layout.allDayLabelWidth) + make.centerY.equalTo(startTimeTitleLabel) + } + + allDayButton.snp.makeConstraints { make in + make.trailing.equalTo(allDayLabel.snp.leading).offset(-Layout.allDayButtonTrailingSpacing) + make.size.equalTo(Layout.allDayButtonSize) + make.centerY.equalTo(startTimeTitleLabel) + } + + timePickerButton.snp.makeConstraints { make in + make.top.equalTo(startTimeTitleLabel.snp.bottom).offset(Layout.titleLabelBottomSpacing) + make.horizontalEdges.equalToSuperview().inset(Layout.horizontalInset) + make.height.equalTo(Layout.inputViewHeight) + } + + registerButton.snp.makeConstraints { make in + make.top.equalTo(timePickerButton.snp.bottom).offset(Layout.registerButtonTopSpacing) + make.horizontalEdges.equalToSuperview().inset(Layout.horizontalInset) + make.height.equalTo(Layout.registerButtonHeight) + make.bottom.equalToSuperview().inset(Layout.registerButtonBottomSpacing) + } + } + + override func bind() { + viewModel.output.namePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] name in + self?.nameInputView.configure(title: name ?? "") + } + .store(in: &cancellables) + + viewModel.output.subRoutinesPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] routines in + var repeatRoutineTitleLabelTopSpacing = Layout.titleLabelTopSpacing + + self? + .subroutineStackView + .subviews + .enumerated() + .forEach { + guard let inputView = $0.element as? RoutineCreationInputView else { return } + + if $0.offset < routines.count { + inputView.configure(title: routines[$0.offset]) + inputView.isHidden = false + } else { + inputView.isHidden = true + } + } + + self?.detailWarningLabel.isHidden = routines.count < 3 + if !(self?.detailWarningLabel.isHidden ?? true) { + repeatRoutineTitleLabelTopSpacing += (Layout.warningLabelHeight + Layout.warningLabelTopSpacing) + self?.repeatRoutineTitleTopConstraint?.update(offset: repeatRoutineTitleLabelTopSpacing) + } + } + .store(in: &cancellables) + + viewModel.output.repeatTypePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] repeatType in + var startTimeLabelTopSpacing = Layout.titleLabelTopSpacing + + switch repeatType { + case .none: + self?.repeatDailyButton.isSelected = false + self?.repeatWeekButton.isSelected = false + self?.weekdaysStackView.isHidden = true + case .daily: + self?.repeatDailyButton.isSelected = true + self?.repeatWeekButton.isSelected = false + self?.weekdaysStackView.isHidden = true + case .week: + self?.repeatDailyButton.isSelected = false + self?.repeatWeekButton.isSelected = true + self?.weekdaysStackView.isHidden = false + startTimeLabelTopSpacing += (Layout.weekDayStackViewHeight + Layout.weekDayStackViewTopSpacing) + } + + self?.startTimeTitleTopConstraint?.update(offset: startTimeLabelTopSpacing) + self?.configureRepeatButton() + } + .store(in: &cancellables) + + viewModel.output.weekDayPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] weekDays in + let weekDaysIndex = weekDays.map { $0.rawValue } + + self?.weekdaysStackView + .subviews + .enumerated() + .forEach { + let isContains = weekDaysIndex.contains($0.offset) + $0.element.backgroundColor = isContains + ? BitnagilColor.lightBlue100 + : .white + } + }) + .store(in: &cancellables) + + viewModel.output.executionTimePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] executionTime in + self?.timePickerButton.configure(title: executionTime.description) + + let allDayButtonImage = executionTime == .allDay + ? BitnagilIcon.checkedIcon + : BitnagilIcon.uncheckedIcon + self?.allDayButton.setImage(allDayButtonImage, for: .normal) + } + .store(in: &cancellables) + + viewModel.output.isRoutineValid + .receive(on: DispatchQueue.main) + .sink { [weak self] isRegisterButtonEnabled in + self?.registerButton.isEnabled = isRegisterButtonEnabled + self?.registerButton.backgroundColor = isRegisterButtonEnabled + ? BitnagilColor.navy500 + : BitnagilColor.navy50 + } + .store(in: &cancellables) + } + + private func configureButtonActions() { + subroutineAdditionButton.addAction( + UIAction { [weak self] _ in + self?.viewModel.action(input: .addSubRoutine) + }, + for: .touchUpInside) + + detailInfoButton.addAction( + UIAction { [weak self] _ in + self?.detailInfoButton.isSelected.toggle() + if self?.detailInfoButton.isSelected ?? false { + self?.detailToolTipView.showTooltip() + } else { + self?.detailToolTipView.hideTooltip() + } + }, + for: .touchUpInside) + + repeatInfoButton.addAction( + UIAction { [weak self] _ in + self?.repeatInfoButton.isSelected.toggle() + if self?.repeatInfoButton.isSelected ?? false { + self?.repeatToolTipView.showTooltip() + } else { + self?.repeatToolTipView.hideTooltip() + } + }, + for: .touchUpInside) + + repeatDailyButton.addAction( + UIAction { [weak self] _ in + self?.viewModel.action(input: .configureRepeatType(type: .daily)) + }, + for: .touchUpInside) + + repeatWeekButton.addAction( + UIAction { [weak self] _ in + self?.viewModel.action(input: .configureRepeatType(type: .week)) + }, + for: .touchUpInside) + + allDayButton.addAction( + UIAction { [weak self] _ in + self?.viewModel.action(input: .configureExecution(type: .allDay)) + }, + for: .touchUpInside) + } + + private func configureSubroutineStackView() { + subroutineStackView.axis = .vertical + subroutineStackView.spacing = Layout.subroutineStackViewSpacing + + RoutineCreationViewModel.RoutineExample + .allCases + .dropFirst() + .enumerated() + .forEach { + let view = RoutineCreationInputView() + let placeholder = "\($0.offset + 1). ex) \($0.element.rawValue)" + view.delegate = self + view.configure(canDelete: true, placeholder: placeholder) + view.snp.makeConstraints { make in + make.height.equalTo(Layout.inputViewHeight) + } + subroutineStackView.addArrangedSubview(view) + } + } + + private func configureWeekdaysStackView() { + weekdaysStackView.isHidden = true + weekdaysStackView.axis = .horizontal + weekdaysStackView.spacing = Layout.weekDayStackViewSpacing + weekdaysStackView.distribution = .fillEqually + + Week.allCases.forEach { week in + let button = UIButton() + button.setTitle(week.koreanValue, for: .normal) + button.layer.cornerRadius = 12 + button.backgroundColor = .white + button.setTitleColor(.black, for: .normal) + button.titleLabel?.font = BitnagilFont.init(style: .body2, weight: .medium).font + button.addAction( + UIAction { [weak self] _ in + self?.viewModel.action(input: .toggleRepeatDay(weekDay: week)) + }, + for: .touchUpInside) + weekdaysStackView.addArrangedSubview(button) + } + } + + private func configureRepeatButton() { + [repeatDailyButton, repeatWeekButton].forEach { + let isSelected = $0.isSelected + let titleColor = isSelected + ? BitnagilColor.navy500 + : BitnagilColor.navy100 + let borderColor = isSelected + ? BitnagilColor.navy500?.cgColor + : BitnagilColor.navy100?.cgColor + + $0.setTitleColor(titleColor, for: .normal) + $0.layer.borderColor = borderColor + } + } + + @objc private func handleTap(_ gesture: UITapGestureRecognizer) { + let location = gesture.location(in: contentView) + + // 탭한 곳이 버튼 영역인 경우 + if detailInfoButton.frame.contains(location) { + repeatInfoButton.isSelected = false + repeatToolTipView.hideTooltip() + return + } + if repeatInfoButton.frame.contains(location) { + detailInfoButton.isSelected = false + detailToolTipView.hideTooltip() + return + } + + // 탭한 곳이 tooltip 영역 밖인 경우 + if !detailToolTipView.frame.contains(location) { + detailInfoButton.isSelected = false + detailToolTipView.hideTooltip() + } + if !repeatToolTipView.frame.contains(location) { + repeatInfoButton.isSelected = false + repeatToolTipView.hideTooltip() + } + } +} + +extension RoutineCreationView: RoutineCreationInputViewDelegate { + func routineCreationInputViewDidTapDeleteButton(_ sender: RoutineCreationInputView) { + guard let index = subroutineStackView.subviews.firstIndex(of: sender) else { return } + + viewModel.action(input: .deleteSubRoutine(index: index)) + } + + func routineCreationInputView(_ sender: RoutineCreationInputView, didChangeText text: String) { + if sender === nameInputView { + viewModel.action(input: .configureName(name: text)) + return + } + + guard let index = subroutineStackView.subviews.firstIndex(of: sender) else { return } + + viewModel.action(input: .configureSubRoutine(name: text, index: index)) + } +} diff --git a/Projects/Presentation/Sources/RoutineCreation/ViewModel/RoutineCreationViewModel.swift b/Projects/Presentation/Sources/RoutineCreation/ViewModel/RoutineCreationViewModel.swift new file mode 100644 index 00000000..fb97fc54 --- /dev/null +++ b/Projects/Presentation/Sources/RoutineCreation/ViewModel/RoutineCreationViewModel.swift @@ -0,0 +1,190 @@ +// +// RoutineCreationViewModel.swift +// Presentation +// +// Created by 이동현 on 7/20/25. +// +import Combine + +final class RoutineCreationViewModel: ViewModel { + enum RepeatType { + case daily + case week + } + + enum RoutineExample: String, CaseIterable { + case wakeUp = "아침에 개운하게 일어나기" + case foldBlanket = "일어나자마자 이불 개기" + case drinkWater = "일어나서 찬물 마시기" + case washFace = "세수하기" + } + + enum ExecutionType: Comparable { + case time(startAt: String) + case allDay + case none + + var description: String { + switch self { + case .time(let time): + return time + case .allDay: + return "하루종일" + case .none: + return "시간 선택" + } + } + } + + enum Input { + case configureName(name: String) + case addSubRoutine + case deleteSubRoutine(index: Int) + case configureSubRoutine(name: String, index: Int) + case configureRepeatType(type: RepeatType) + case toggleRepeatDay(weekDay: Week) + case toggleRepeatAllDay + case configureExecution(type: ExecutionType) + case registerRoutine + } + + struct Output { + let namePublisher: AnyPublisher + let subRoutinesPublisher: AnyPublisher<[String], Never> + let repeatTypePublisher: AnyPublisher + let weekDayPublisher: AnyPublisher, Never> + let executionTimePublisher: AnyPublisher + let isRoutineValid: AnyPublisher + } + + private(set) var output: Output + private let nameSubject = CurrentValueSubject("") + private let subRoutinesSubject = CurrentValueSubject<[String], Never>([]) + private let repeatTypeSubject = CurrentValueSubject(nil) + private let weekDaySubject = CurrentValueSubject, Never>([]) + private let executionTimeSubject = CurrentValueSubject(.none) + private let checkRoutinePublisher = PassthroughSubject() + + init() { + output = Output( + namePublisher: nameSubject.eraseToAnyPublisher(), + subRoutinesPublisher: subRoutinesSubject.eraseToAnyPublisher(), + repeatTypePublisher: repeatTypeSubject.eraseToAnyPublisher(), + weekDayPublisher: weekDaySubject.eraseToAnyPublisher(), + executionTimePublisher: executionTimeSubject.eraseToAnyPublisher(), + isRoutineValid: checkRoutinePublisher.eraseToAnyPublisher()) + } + + func action(input: Input) { + switch input { + case .configureName(let name): + configureName(name: name) + case .addSubRoutine: + addSubRoutine() + case .deleteSubRoutine(let index): + deleteSubRoutine(index: index) + case .configureSubRoutine(let name, let index): + configureSubroutine(name: name, index: index) + case .configureRepeatType(let type): + configureRepeatType(type: type) + case .toggleRepeatDay(let weekDay): + configureWeekDay(weekDay: weekDay) + case .toggleRepeatAllDay: + configurExecutionTime(time: .allDay) + case .configureExecution(let startTime): + configurExecutionTime(time: startTime) + case .registerRoutine: + registerRoutine() + } + + updateIsRoutineValid() + } + + private func configureName(name: String) { + nameSubject.send(name) + } + + private func addSubRoutine() { + var subRoutines = subRoutinesSubject.value + guard subRoutines.count <= 3 else { return } + + subRoutines.append("") + subRoutinesSubject.send(subRoutines) + } + + private func deleteSubRoutine(index: Int) { + var subRoutines = subRoutinesSubject.value + guard + index >= 0, + index < subRoutines.count + else { return } + + subRoutines.remove(at: index) + subRoutinesSubject.send(subRoutines) + } + + private func configureSubroutine(name: String, index: Int) { + var subRoutines = subRoutinesSubject.value + guard + index >= 0, + index < subRoutines.count + else { return } + + subRoutines[index] = name + subRoutinesSubject.send(subRoutines) + } + + private func configureRepeatType(type: RepeatType) { + var repeatType = repeatTypeSubject.value + + repeatType = repeatType == type + ? nil + : type + + if repeatType != .week { + weekDaySubject.send([]) + } + repeatTypeSubject.send(repeatType) + } + + private func configureWeekDay(weekDay: Week) { + var weekDays = weekDaySubject.value + + if weekDays.contains(weekDay) { + weekDays.remove(weekDay) + } else { + weekDays.insert(weekDay) + } + + weekDaySubject.send(weekDays) + } + + private func configurExecutionTime(time: ExecutionType) { + if + time == .allDay, + executionTimeSubject.value == .allDay + { + executionTimeSubject.send(.none) + return + } + + executionTimeSubject.send(time) + } + + private func updateIsRoutineValid() { + guard + let name = nameSubject.value, + !name.isEmpty, + executionTimeSubject.value != .none + else { + checkRoutinePublisher.send(false) + return + } + + checkRoutinePublisher.send(true) + } + + private func registerRoutine() { + // API 호출 + } +}