diff --git a/Projects/Presentation/Resources/Images.xcassets/Common/report_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Common/report_icon.imageset/Contents.json new file mode 100644 index 00000000..61b05e78 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/Common/report_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "report_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "report_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "report_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/Common/report_icon.imageset/report_icon.png b/Projects/Presentation/Resources/Images.xcassets/Common/report_icon.imageset/report_icon.png new file mode 100644 index 00000000..76ff07df Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Common/report_icon.imageset/report_icon.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Common/report_icon.imageset/report_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/Common/report_icon.imageset/report_icon@2x.png new file mode 100644 index 00000000..6337def0 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Common/report_icon.imageset/report_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Common/report_icon.imageset/report_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/Common/report_icon.imageset/report_icon@3x.png new file mode 100644 index 00000000..5ccda036 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Common/report_icon.imageset/report_icon@3x.png differ diff --git a/Projects/Presentation/Sources/Common/Component/FloatingMenuView.swift b/Projects/Presentation/Sources/Common/Component/FloatingMenuView.swift index c60030b2..2b9790a8 100644 --- a/Projects/Presentation/Sources/Common/Component/FloatingMenuView.swift +++ b/Projects/Presentation/Sources/Common/Component/FloatingMenuView.swift @@ -9,22 +9,47 @@ import SnapKit import UIKit protocol FloatingMenuViewDelegate: AnyObject { + func floatingMenuDidTapReportButton(_ sender: FloatingMenuView) func floatingMenuDidTapRegisterRoutineButton(_ sender: FloatingMenuView) } final class FloatingMenuView: UIView { private enum Layout { - static let registerRoutineButtonHeight: CGFloat = 24 - static let registerRoutineIconSize: CGFloat = 24 - static let registerRoutineLabelLeadingSpacing: CGFloat = 14 - static let registerRoutineButtonWidth: CGFloat = 112 - static let registerRoutineLabelHeight: CGFloat = 20 + static let menuButtonStackViewSpacing: CGFloat = 24 + static let menuButtonHeight: CGFloat = 24 + static let menuIconSize: CGFloat = 24 + static let menuLabelLeadingSpacing: CGFloat = 14 + static let menuButtonWidth: CGFloat = 112 + static let menuLabelHeight: CGFloat = 20 + } + + private enum FloatingMenu { + case addRoutine + case report + + var menuIcon: UIImage? { + switch self { + case .addRoutine: + return BitnagilIcon.addRoutineIcon + case .report: + return BitnagilIcon.reportIcon + } + } + + var menuLabel: String { + switch self { + case .addRoutine: + return "루틴 등록" + case .report: + return "제보하기" + } + } } private let containerView = UIView() - private let registerRoutineButton = UIButton() - private let registerRoutineIconView = UIImageView() - private let registerRoutineLabel = UILabel() + private let menuButtonStackView = UIStackView() + private var reportButton = UIButton() + private var registerRoutineButton = UIButton() weak var delegate: FloatingMenuViewDelegate? init() { @@ -42,12 +67,18 @@ final class FloatingMenuView: UIView { containerView.layer.masksToBounds = true containerView.layer.cornerRadius = 12 - registerRoutineIconView.image = BitnagilIcon.addRoutineIcon + menuButtonStackView.axis = .vertical + menuButtonStackView.spacing = Layout.menuButtonStackViewSpacing - registerRoutineLabel.text = "루틴 등록" - registerRoutineLabel.font = BitnagilFont(style: .body2, weight: .medium).font - registerRoutineLabel.textColor = BitnagilColor.gray30 + reportButton = makeMenuButton(menu: .report) + reportButton.addAction( + UIAction { [weak self] _ in + guard let self else { return } + self.delegate?.floatingMenuDidTapReportButton(self) + }, + for: .touchUpInside) + registerRoutineButton = makeMenuButton(menu: .addRoutine) registerRoutineButton.addAction( UIAction { [weak self] _ in guard let self else { return } @@ -58,31 +89,56 @@ final class FloatingMenuView: UIView { private func configureLayout() { addSubview(containerView) - containerView.addSubview(registerRoutineButton) - [registerRoutineIconView, registerRoutineLabel].forEach { - registerRoutineButton.addSubview($0) + [reportButton, registerRoutineButton].forEach { menuButton in + menuButtonStackView.addArrangedSubview(menuButton) } + containerView.addSubview(menuButtonStackView) containerView.snp.makeConstraints { make in make.edges.equalToSuperview() } + menuButtonStackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + } + + reportButton.snp.makeConstraints { make in + make.height.equalTo(Layout.menuButtonHeight) + make.width.equalTo(Layout.menuButtonWidth) + } + registerRoutineButton.snp.makeConstraints { make in - make.center.equalToSuperview() - make.height.equalTo(Layout.registerRoutineButtonHeight) - make.width.equalTo(Layout.registerRoutineButtonWidth) + make.height.equalTo(Layout.menuButtonHeight) + make.width.equalTo(Layout.menuButtonWidth) + } + } + + private func makeMenuButton(menu: FloatingMenu) -> UIButton { + let menuButton = UIButton() + let menuIconView = UIImageView() + let menuLabel = UILabel() + + menuIconView.image = menu.menuIcon + menuLabel.text = menu.menuLabel + menuLabel.font = BitnagilFont(style: .body2, weight: .medium).font + menuLabel.textColor = BitnagilColor.gray30 + + [menuIconView, menuLabel].forEach { + menuButton.addSubview($0) } - registerRoutineIconView.snp.makeConstraints { make in + menuIconView.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.equalToSuperview() - make.size.equalTo(Layout.registerRoutineIconSize) + make.size.equalTo(Layout.menuIconSize) } - registerRoutineLabel.snp.makeConstraints { make in - make.leading.equalTo(registerRoutineIconView.snp.trailing).offset(Layout.registerRoutineLabelLeadingSpacing) + menuLabel.snp.makeConstraints { make in + make.leading.equalTo(menuIconView.snp.trailing).offset(Layout.menuLabelLeadingSpacing) make.centerY.equalToSuperview() - make.height.equalTo(Layout.registerRoutineLabelHeight) + make.height.equalTo(Layout.menuLabelHeight) } + + return menuButton } } diff --git a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift index 314217fb..25cf9508 100644 --- a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift +++ b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift @@ -15,6 +15,7 @@ enum BitnagilIcon { // MARK: - Common Icons static let backButtonIcon = UIImage(named: "back_button_icon", in: bundle, with: nil) static let plusIcon = UIImage(named: "plus_icon", in: bundle, with: nil)?.withRenderingMode(.alwaysTemplate) + static let reportIcon = UIImage(named: "report_icon", in: bundle, with: nil) static let bitnagilChevronIcon = UIImage(named: "bitnagil_chevron_icon", in: bundle, with: nil) static func bitnagilChevronIcon(direction: Direction) -> UIImage? { return BitnagilIcon.bitnagilChevronIcon?.rotate(degrees: direction.rotation)?.withRenderingMode(.alwaysTemplate) diff --git a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift index 06b4dd42..449a4542 100644 --- a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift +++ b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift @@ -127,5 +127,9 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol { return ReportViewModel(reportUseCase: reportUseCase) } + + DIContainer.shared.register(type: ReportDetailViewModel.self) { container in + return ReportDetailViewModel() + } } } diff --git a/Projects/Presentation/Sources/Home/View/HomeViewController.swift b/Projects/Presentation/Sources/Home/View/HomeViewController.swift index 6333b492..1d586b0a 100644 --- a/Projects/Presentation/Sources/Home/View/HomeViewController.swift +++ b/Projects/Presentation/Sources/Home/View/HomeViewController.swift @@ -45,7 +45,7 @@ final class HomeViewController: BaseViewController { static let floatingButtonBottomSpacing: CGFloat = 19 static let floatingButtonSize: CGFloat = 52 static let floatingMenuBottomSpacing: CGFloat = 16 - static let floatingMenuHeight: CGFloat = 56 + static let floatingMenuHeight: CGFloat = 104 static let floatingMenuWidth: CGFloat = 144 } @@ -664,6 +664,18 @@ extension HomeViewController: RoutineViewDelegate { // MARK: FloatingMenuViewDelegate extension HomeViewController: FloatingMenuViewDelegate { + func floatingMenuDidTapReportButton(_ sender: FloatingMenuView) { + toggleFloatingButton() + // TODO: 제보하기 뷰로 이동 (현재는 제보 detailView) + guard let reportDetailViewModel = DIContainer.shared.resolve(type: ReportDetailViewModel.self) + else { fatalError("reportDetailViewModel 의존성이 등록되지 않았습니다.") } + + let reportDetailViewController = ReportDetailViewController(viewModel: reportDetailViewModel) + reportDetailViewController.hidesBottomBarWhenPushed = true + + self.navigationController?.pushViewController(reportDetailViewController, animated: true) + } + func floatingMenuDidTapRegisterRoutineButton(_ sender: FloatingMenuView) { toggleFloatingButton() guard let routineCreationViewModel = DIContainer.shared.resolve(type: RoutineCreationViewModel.self) else { diff --git a/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift b/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift index b5bb5cd5..17fbe427 100644 --- a/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift +++ b/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift @@ -30,7 +30,7 @@ final class RecommendedRoutineViewController: BaseViewController { + private enum Layout { + static let horizontalMargin: CGFloat = 20 + static let photoStackViewSpacing: CGFloat = 8 + static let photoStackViewTopSpacing: CGFloat = 14 + static let photoSize: CGFloat = 74 + static let contentStackViewSpacing: CGFloat = 28 + static let contentStackViewTopSpacing: CGFloat = 23 + static let reportStatusViewTopSpacing: CGFloat = 74 + static let reportStatusViewWidth: CGFloat = 65 + static let reportStatusViewHeight: CGFloat = 26 + static let dateLabelTopSpacing: CGFloat = 6 + static let reportContentBackgroudViewTopSpacing: CGFloat = 8 + static let reportContentDescriptionVerticalMargin: CGFloat = 16 + } + + private enum ReportDetailContent: CaseIterable { + case title + case category + case description + case location + + var title: String { + switch self { + case .title: + return "제목" + case .category: + return "카테고리" + case .description: + return "상세 제보 내용" + case .location: + return "내 위치" + } + } + } + + private let scrollView = UIScrollView() + private let contentView = UIView() + private let reportStatusView = UIView() + private let dateLabel = UILabel() + private let photoStackView = UIStackView() + private let contentStackView = UIStackView() + private let titleContentLabel = UILabel() + private let categoryLabel = UILabel() + private let detailDescriptionLabel = UILabel() + private let locationLabel = UILabel() + private var cancellables: Set = [] + + override func viewDidLoad() { + super.viewDidLoad() + viewModel.action(input: .fetchReportDetail) + } + + override func configureAttribute() { + view.backgroundColor = .white + configureCustomNavigationBar(navigationBarStyle: .withBackButton(title: "제보하기")) + + scrollView.showsVerticalScrollIndicator = false + // TODO: 추후 공통 component로 교체 + reportStatusView.layer.masksToBounds = true + reportStatusView.layer.cornerRadius = 6 + reportStatusView.backgroundColor = BitnagilColor.green10 + + dateLabel.font = BitnagilFont(style: .body1, weight: .semiBold).font + dateLabel.textColor = BitnagilColor.gray10 + + photoStackView.axis = .horizontal + photoStackView.spacing = Layout.photoStackViewSpacing + + contentStackView.axis = .vertical + contentStackView.spacing = Layout.contentStackViewSpacing + + ReportDetailContent.allCases.forEach { reportDetailContent in + let reportContentView = makeReportContentView(reportDetailContentType: reportDetailContent) + contentStackView.addArrangedSubview(reportContentView) + } + } + + override func configureLayout() { + let safeArea = view.safeAreaLayoutGuide + + view.addSubview(scrollView) + scrollView.addSubview(contentView) + [reportStatusView, dateLabel, photoStackView, contentStackView].forEach { + contentView.addSubview($0) + } + + scrollView.snp.makeConstraints { make in + make.edges.equalTo(safeArea) + } + + contentView.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide) + make.width.equalTo(scrollView.frameLayoutGuide) + } + + reportStatusView.snp.makeConstraints { make in + make.top.equalTo(contentView).offset(Layout.reportStatusViewTopSpacing) + make.width.equalTo(Layout.reportStatusViewWidth) + make.height.equalTo(Layout.reportStatusViewHeight) + make.leading.equalTo(contentView).offset(Layout.horizontalMargin) + } + + dateLabel.snp.makeConstraints { make in + make.top.equalTo(reportStatusView.snp.bottom).offset(Layout.dateLabelTopSpacing) + make.leading.equalTo(contentView).offset(Layout.horizontalMargin) + } + + photoStackView.snp.makeConstraints { make in + make.top.equalTo(dateLabel.snp.bottom).offset(Layout.photoStackViewTopSpacing) + make.leading.equalTo(contentView).offset(Layout.horizontalMargin) + } + + contentStackView.snp.makeConstraints { make in + make.top.equalTo(photoStackView.snp.bottom).offset(Layout.contentStackViewTopSpacing) + make.horizontalEdges.equalTo(contentView).inset(Layout.horizontalMargin) + make.bottom.equalToSuperview().offset(-40) + } + } + + override func bind() { + viewModel.output.reportDetailPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] reportDetail in + self?.fillReportContent(reportDetail: reportDetail) + } + .store(in: &cancellables) + } + + private func makeReportContentView(reportDetailContentType: ReportDetailContent) -> UIView { + let reportContentView = UIView() + let titleLabel = UILabel() + let backgroudView = UIView() + + titleLabel.text = reportDetailContentType.title + titleLabel.font = BitnagilFont(style: .body2, weight: .semiBold).font + titleLabel.textColor = BitnagilColor.gray10 + + backgroudView.layer.masksToBounds = true + backgroudView.layer.cornerRadius = 12 + backgroudView.backgroundColor = BitnagilColor.gray99 + + var descriptionLabel: UILabel + switch reportDetailContentType { + case .title: + descriptionLabel = titleContentLabel + case .category: + descriptionLabel = categoryLabel + case .description: + descriptionLabel = detailDescriptionLabel + descriptionLabel.numberOfLines = 0 + case .location: + descriptionLabel = locationLabel + } + descriptionLabel.font = BitnagilFont(style: .body2, weight: .medium).font + descriptionLabel.textColor = BitnagilColor.gray10 + + backgroudView.addSubview(descriptionLabel) + [titleLabel, backgroudView].forEach { + reportContentView.addSubview($0) + } + + titleLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview() + make.height.equalTo(Layout.horizontalMargin) + } + + backgroudView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(Layout.reportContentBackgroudViewTopSpacing) + make.horizontalEdges.equalToSuperview() + make.bottom.equalToSuperview() + } + + descriptionLabel.snp.makeConstraints { make in + make.verticalEdges.equalToSuperview().inset(Layout.reportContentDescriptionVerticalMargin) + make.horizontalEdges.equalToSuperview().inset(Layout.horizontalMargin) + } + + return reportContentView + } + + private func fillReportContent(reportDetail: ReportDetail?) { + guard let reportDetail else { return } + + let photoView = UIView() + photoView.backgroundColor = BitnagilColor.gray30 + photoView.layer.masksToBounds = true + photoView.layer.cornerRadius = 9.25 + photoView.snp.makeConstraints { make in + make.size.equalTo(Layout.photoSize) + } + photoStackView.addArrangedSubview(photoView) + + dateLabel.text = reportDetail.date + titleContentLabel.text = reportDetail.title + categoryLabel.text = reportDetail.category.name + detailDescriptionLabel.attributedText = BitnagilFont(style: .body2, weight: .medium).attributedString(text: reportDetail.description) + locationLabel.text = reportDetail.location + } +} diff --git a/Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift b/Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift new file mode 100644 index 00000000..5d61fee7 --- /dev/null +++ b/Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift @@ -0,0 +1,46 @@ +// +// ReportDetailViewModel.swift +// Presentation +// +// Created by 최정인 on 11/18/25. +// + +import Combine +import Domain + +final class ReportDetailViewModel: ViewModel { + enum Input { + case fetchReportDetail + } + + struct Output { + let reportDetailPublisher: AnyPublisher + } + + private(set) var output: Output + private let reportDetailSubject = CurrentValueSubject(nil) + + init() { + self.output = Output( + reportDetailPublisher: reportDetailSubject.eraseToAnyPublisher() + ) + } + + func action(input: Input) { + switch input { + case .fetchReportDetail: + fetchReportDetail() + } + } + + private func fetchReportDetail() { + let report = ReportDetail( + date: "2025.11.03 (금)", + title: "가로등이 깜빡거려요.", + category: .water, + description: "가로등이 깜박거리고 치지직 거려서 곧 터질 것 같아요... 햇살이 유리창 너머로 스며들며 방 안을 부드럽게 채운다. 커피 향이 퍼지고, 어제의 고민이 조금은 멀게 느껴진다. 오늘은 완벽하지 않아도 괜찮다. 천천히 숨을 고르고, 다시 한 걸음 내딛으면 된다. 이게 뭐람.", + location: "서울특별시 강남구 삼성동") + + reportDetailSubject.send(report) + } +} diff --git a/SupportingFiles/Info.plist b/SupportingFiles/Info.plist index 3becccad..43523208 100644 --- a/SupportingFiles/Info.plist +++ b/SupportingFiles/Info.plist @@ -2,14 +2,6 @@ - NSLocationWhenInUseUsageDescription - 위치 정보 사용을 허용하시겠습니까? - LSApplicationCategoryType - - NSPhotoLibraryUsageDescription - 앨범에서 사진을 선택합니다. 허용하시겠습니까? - NSCameraUsageDescription - 카메라를 사용해 사진을 촬영합니다. 허용하시겠습니까? BaseURL $(BASE_URL) CFBundleExecutable @@ -23,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.0 + 2.0.0 CFBundleURLTypes @@ -37,15 +29,23 @@ CFBundleVersion 1 - KakaoNativeKey - $(KAKAO_NATIVE_KEY) KakaoAPIKey $(KAKAO_API_KEY) + KakaoNativeKey + $(KAKAO_NATIVE_KEY) + LSApplicationCategoryType + LSApplicationQueriesSchemes kakaokompassauth itms-apps + NSCameraUsageDescription + 카메라를 사용해 사진을 촬영합니다. 허용하시겠습니까? + NSLocationWhenInUseUsageDescription + 위치 정보 사용을 허용하시겠습니까? + NSPhotoLibraryUsageDescription + 앨범에서 사진을 선택합니다. 허용하시겠습니까? UIAppFonts Pretendard-Bold.otf