diff --git a/Projects/Domain/Sources/Entity/Enum/ReportType.swift b/Projects/Domain/Sources/Entity/Enum/ReportType.swift new file mode 100644 index 00000000..492ad77a --- /dev/null +++ b/Projects/Domain/Sources/Entity/Enum/ReportType.swift @@ -0,0 +1,12 @@ +// +// ReportType.swift +// Domain +// +// Created by 이동현 on 11/9/25. +// + +public enum ReportType: String, CaseIterable { + case lamp + case road + case etc +} diff --git a/Projects/Domain/Sources/Entity/RoutineCompletionEntity.swift b/Projects/Domain/Sources/Entity/RoutineCompletionEntity.swift new file mode 100644 index 00000000..e038fec7 --- /dev/null +++ b/Projects/Domain/Sources/Entity/RoutineCompletionEntity.swift @@ -0,0 +1,28 @@ +// +// RoutineCompletionEntity.swift +// Domain +// +// Created by 최정인 on 8/6/25. +// + +public struct RoutineCompletionEntity { + public let performedDate: String + public let routineId: String + public let completeYn: Bool + public let historySeq: Int + public let routineType: String + + public init( + performedDate: String, + routineId: String, + completeYn: Bool, + historySeq: Int, + routineType: String + ) { + self.performedDate = performedDate + self.routineId = routineId + self.completeYn = completeYn + self.historySeq = historySeq + self.routineType = routineType + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Report/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/Report/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/Contents.json new file mode 100644 index 00000000..7dac3331 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "camera_icon@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "camera_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "camera_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@1x.png b/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@1x.png new file mode 100644 index 00000000..b650f4a3 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@1x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@2x.png new file mode 100644 index 00000000..dee5cd53 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@3x.png new file mode 100644 index 00000000..ad07be98 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Report/camera_icon.imageset/camera_icon@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/Contents.json new file mode 100644 index 00000000..940e4c33 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "location_icon@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "location_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "location_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/location_icon@1x.png b/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/location_icon@1x.png new file mode 100644 index 00000000..92ea656b Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/location_icon@1x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/location_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/location_icon@2x.png new file mode 100644 index 00000000..6945e5eb Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/location_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/location_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/location_icon@3x.png new file mode 100644 index 00000000..6ac25cc1 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Report/location_icon.imageset/location_icon@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/Contents.json new file mode 100644 index 00000000..a216ddb7 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "round_delete_icon@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "round_delete_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "round_delete_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/round_delete_icon@1x.png b/Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/round_delete_icon@1x.png new file mode 100644 index 00000000..bb8490dc Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/round_delete_icon@1x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/round_delete_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/round_delete_icon@2x.png new file mode 100644 index 00000000..968feef5 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/round_delete_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/round_delete_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/round_delete_icon@3x.png new file mode 100644 index 00000000..cc1373ee Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Report/round_delete_icon.imageset/round_delete_icon@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill.png new file mode 100644 index 00000000..9052e98d Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill@2x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill@2x.png new file mode 100644 index 00000000..c1dd8a08 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill@3x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill@3x.png new file mode 100644 index 00000000..287b5936 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/home_fill_icon.imageset/navigation=home, status=fill@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill.png new file mode 100644 index 00000000..71aa5cf3 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill@2x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill@2x.png new file mode 100644 index 00000000..ab6522f5 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill@3x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill@3x.png new file mode 100644 index 00000000..460990bc Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/mypage_fill_icon.imageset/navigation=_mypage, status=fill@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/recommend_fill_icon.imageset/navigation=recommend, status=fill.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/recommend_fill_icon.imageset/navigation=recommend, status=fill.png new file mode 100644 index 00000000..c61109d6 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/recommend_fill_icon.imageset/navigation=recommend, status=fill.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/recommend_fill_icon.imageset/navigation=recommend, status=fill@2x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/recommend_fill_icon.imageset/navigation=recommend, status=fill@2x.png new file mode 100644 index 00000000..232efe63 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/recommend_fill_icon.imageset/navigation=recommend, status=fill@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/recommend_fill_icon.imageset/navigation=recommend, status=fill@3x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/recommend_fill_icon.imageset/navigation=recommend, status=fill@3x.png new file mode 100644 index 00000000..d8b74bdb Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/recommend_fill_icon.imageset/navigation=recommend, status=fill@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_empty_icon.imageset/navigation=report, status=empty.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_empty_icon.imageset/navigation=report, status=empty.png new file mode 100644 index 00000000..95408ec0 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_empty_icon.imageset/navigation=report, status=empty.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_empty_icon.imageset/navigation=report, status=empty@2x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_empty_icon.imageset/navigation=report, status=empty@2x.png new file mode 100644 index 00000000..f67b30e4 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_empty_icon.imageset/navigation=report, status=empty@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_empty_icon.imageset/navigation=report, status=empty@3x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_empty_icon.imageset/navigation=report, status=empty@3x.png new file mode 100644 index 00000000..e67c826e Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_empty_icon.imageset/navigation=report, status=empty@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_fill_icon.imageset/navigation=report, status=fill.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_fill_icon.imageset/navigation=report, status=fill.png new file mode 100644 index 00000000..52c2958a Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_fill_icon.imageset/navigation=report, status=fill.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_fill_icon.imageset/navigation=report, status=fill@2x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_fill_icon.imageset/navigation=report, status=fill@2x.png new file mode 100644 index 00000000..2e1d83ba Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_fill_icon.imageset/navigation=report, status=fill@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_fill_icon.imageset/navigation=report, status=fill@3x.png b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_fill_icon.imageset/navigation=report, status=fill@3x.png new file mode 100644 index 00000000..e68c2494 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/TabBarIcon/report_fill_icon.imageset/navigation=report, status=fill@3x.png differ diff --git a/Projects/Presentation/Sources/Common/Component/RequiredTitleLabel.swift b/Projects/Presentation/Sources/Common/Component/RequiredTitleLabel.swift new file mode 100644 index 00000000..148edd6f --- /dev/null +++ b/Projects/Presentation/Sources/Common/Component/RequiredTitleLabel.swift @@ -0,0 +1,61 @@ +// +// RequiredTitleLabel.swift +// Presentation +// +// Created by 이동현 on 11/8/25. +// + +import SnapKit +import UIKit + +final class RequiredTitleLabel: UIView { + private enum Layout { + static let asteriskLeadingSpacing: CGFloat = 3 + } + + private let titleLabel = UILabel() + private let asteriskLabel = UILabel() + + init(title: String) { + super.init(frame: .zero) + configureAttribute(title: title) + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute(title: String) { + titleLabel.font = BitnagilFont.init( + style: .body2, + weight: .semiBold + ).font + titleLabel.textColor = BitnagilColor.gray10 + titleLabel.text = title + + asteriskLabel.text = "*" + asteriskLabel.font = BitnagilFont.init( + style: .body1, + weight: .semiBold + ).font + asteriskLabel.textColor = BitnagilColor.error + } + + private func configureLayout() { + addSubview(titleLabel) + addSubview(asteriskLabel) + + titleLabel.snp.makeConstraints { make in + make.verticalEdges.leading.equalToSuperview() + } + + asteriskLabel.snp.makeConstraints { make in + make.leading + .equalTo(titleLabel.snp.trailing) + .offset(Layout.asteriskLeadingSpacing) + + make.top.equalTo(titleLabel) + } + } +} diff --git a/Projects/Presentation/Sources/Common/Component/SelectableItemTableView.swift b/Projects/Presentation/Sources/Common/Component/SelectableItemTableView.swift index bb2d6f12..8eb5da26 100644 --- a/Projects/Presentation/Sources/Common/Component/SelectableItemTableView.swift +++ b/Projects/Presentation/Sources/Common/Component/SelectableItemTableView.swift @@ -19,17 +19,20 @@ final class SelectableItemTableView } - private(set) var output: Output + let output: Output private var routines: [String: [Routine]] = [:] private var routinesCompleted: [String: Bool] = [:] private let nicknameSubject = CurrentValueSubject("") diff --git a/Projects/Presentation/Sources/Report/Model/PhotoItem.swift b/Projects/Presentation/Sources/Report/Model/PhotoItem.swift new file mode 100644 index 00000000..140d4879 --- /dev/null +++ b/Projects/Presentation/Sources/Report/Model/PhotoItem.swift @@ -0,0 +1,18 @@ +// +// PhotoItem.swift +// Presentation +// +// Created by 이동현 on 11/9/25. +// + +import Foundation + +public struct PhotoItem: Hashable { + public let id: UUID + public let data: Data + + public init(id: UUID = .init(), data: Data) { + self.id = id + self.data = data + } +} diff --git a/Projects/Presentation/Sources/Report/Model/ReportType+.swift b/Projects/Presentation/Sources/Report/Model/ReportType+.swift new file mode 100644 index 00000000..829e8248 --- /dev/null +++ b/Projects/Presentation/Sources/Report/Model/ReportType+.swift @@ -0,0 +1,36 @@ +// +// ReportType+.swift +// Presentation +// +// Created by 이동현 on 11/9/25. +// + +import Domain + +extension ReportType: SelectableItem { + var id: Int { + switch self { + case .lamp: + return 0 + case .road: + return 1 + case .etc: + return 2 + } + } + + var displayName: String? { + return nil + } + + var description: String { + switch self { + case .lamp: + return "가로등" + case .road: + return "도로" + case .etc: + return "기타" + } + } +} diff --git a/Projects/Presentation/Sources/Report/Model/SelectPhotoType.swift b/Projects/Presentation/Sources/Report/Model/SelectPhotoType.swift new file mode 100644 index 00000000..62b3766f --- /dev/null +++ b/Projects/Presentation/Sources/Report/Model/SelectPhotoType.swift @@ -0,0 +1,35 @@ +// +// SelectPhotoType.swift +// Presentation +// +// Created by 이동현 on 11/9/25. +// + +enum SelectPhotoType: String, CaseIterable { + case camera + case library +} + +extension SelectPhotoType: SelectableItem { + var id: Int { + switch self { + case .camera: + return 0 + case .library: + return 1 + } + } + + var displayName: String? { + return nil + } + + var description: String { + switch self { + case .camera: + return "직접 촬영하기" + case .library: + return "사진 라이브러리에서 선택" + } + } +} diff --git a/Projects/Presentation/Sources/Report/View/LocationButton.swift b/Projects/Presentation/Sources/Report/View/LocationButton.swift new file mode 100644 index 00000000..b45865b4 --- /dev/null +++ b/Projects/Presentation/Sources/Report/View/LocationButton.swift @@ -0,0 +1,41 @@ +// +// LocationButton.swift +// Presentation +// +// Created by 이동현 on 11/8/25. +// + +import SnapKit +import UIKit + +final class LocationButton: UIButton { + private enum Layout { + static let locationImageSize: CGFloat = 24 + } + + private let locationImageView = UIImageView() + + 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.orange500 + locationImageView.image = BitnagilIcon.locationIcon + } + + private func configureLayout() { + addSubview(locationImageView) + + locationImageView.snp.makeConstraints { make in + make.size.equalTo(Layout.locationImageSize) + make.center.equalToSuperview() + } + } +} diff --git a/Projects/Presentation/Sources/Report/View/ReportCameraButton.swift b/Projects/Presentation/Sources/Report/View/ReportCameraButton.swift new file mode 100644 index 00000000..8396a75a --- /dev/null +++ b/Projects/Presentation/Sources/Report/View/ReportCameraButton.swift @@ -0,0 +1,74 @@ +// +// ReportCameraButton.swift +// Presentation +// +// Created by 이동현 on 11/8/25. +// + +import SnapKit +import UIKit + +final class ReportCameraButton: UIButton { + private enum Layout { + static let imageSize: CGFloat = 20 + static let imageTopSpacing: CGFloat = 15 + } + + private let cameraImageView = UIImageView() + private let imageCountLabel = 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() { + backgroundColor = BitnagilColor.gray99 + cameraImageView.image = BitnagilIcon.cameraIcon + configure(imageCount: 0, maxCount: 3) + } + + private func configureLayout() { + addSubview(cameraImageView) + addSubview(imageCountLabel) + + cameraImageView.snp.makeConstraints { make in + make.size.equalTo(Layout.imageSize) + make.centerX.equalToSuperview() + make.top.equalToSuperview().offset(Layout.imageTopSpacing) + } + + imageCountLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.top.equalTo(cameraImageView.snp.bottom) + } + } + + func configure(imageCount: Int, maxCount: Int) { + let text = "\(imageCount)/\(maxCount)" + + let nsString = text as NSString + let firstRange = nsString.rangeOfComposedCharacterSequence(at: .zero) + + let font = BitnagilFont.init( + style: .button1, + weight: .regular).font + + let attribute = NSMutableAttributedString( + string: text, + attributes: [ + .foregroundColor: BitnagilColor.gray70 ?? .black, + .font: font]) + attribute + .addAttributes( + [.foregroundColor: BitnagilColor.gray10 ?? .black], + range: firstRange) + imageCountLabel.attributedText = attribute + } +} diff --git a/Projects/Presentation/Sources/Report/View/ReportPhotoCollectionViewCell.swift b/Projects/Presentation/Sources/Report/View/ReportPhotoCollectionViewCell.swift new file mode 100644 index 00000000..2cfbb05e --- /dev/null +++ b/Projects/Presentation/Sources/Report/View/ReportPhotoCollectionViewCell.swift @@ -0,0 +1,89 @@ +// +// ReportPhotoCollectionViewCell.swift +// Presentation +// +// Created by 이동현 on 11/8/25. +// + +import SnapKit +import UIKit + +protocol ReportPhotoCollectionViewCellDelegate: AnyObject { + func reportPhotoCollectionViewCellWillDeleteCell(_ cell: ReportPhotoCollectionViewCell, uuid: UUID) +} + +final class ReportPhotoCollectionViewCell: UICollectionViewCell { + private enum Layout { + static let imageCornerRadius: CGFloat = 8 + static let imageSize: CGFloat = 64 + static let deleteButtonSize: CGFloat = 16 + } + + private let imageView = UIImageView() + private let deleteButton = UIButton() + private let deleteButtonImageView = UIImageView() + private var uuid: UUID? + + weak var delegate: ReportPhotoCollectionViewCellDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.image = nil + } + + private func configureAttribute() { + imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = Layout.imageCornerRadius + imageView.layer.masksToBounds = true + + deleteButton.addAction( + UIAction { [weak self] _ in + guard + let self, + let uuid + else { return } + + self.delegate?.reportPhotoCollectionViewCellWillDeleteCell(self, uuid: uuid) + }, + for: .touchUpInside) + deleteButton.backgroundColor = .clear + deleteButtonImageView.image = BitnagilIcon.roundDeleteIcon + } + + private func configureLayout() { + addSubview(imageView) + addSubview(deleteButtonImageView) + addSubview(deleteButton) + + imageView.snp.makeConstraints { make in + make.leading.equalToSuperview() + make.bottom.equalToSuperview() + make.size.equalTo(Layout.imageSize) + } + + deleteButton.snp.makeConstraints { make in + make.size.equalTo(Layout.deleteButtonSize) + make.centerY.equalTo(imageView.snp.top) + make.centerX.equalTo(imageView.snp.trailing) + } + + deleteButtonImageView.snp.makeConstraints { make in + make.edges.equalTo(deleteButton) + } + } + + func configure(item: PhotoItem) { + imageView.image = UIImage(data: item.data) + uuid = item.id + } +} diff --git a/Projects/Presentation/Sources/Report/View/ReportTextView.swift b/Projects/Presentation/Sources/Report/View/ReportTextView.swift new file mode 100644 index 00000000..f0b65fab --- /dev/null +++ b/Projects/Presentation/Sources/Report/View/ReportTextView.swift @@ -0,0 +1,167 @@ +// +// ReportTextView.swift +// Presentation +// +// Created by 이동현 on 11/8/25. +// + +import SnapKit +import UIKit + +protocol ReportTextViewDelegate: AnyObject { + func reportTextViewDidChanged(_ reportTextView: ReportTextView, text: String?) + func reportTextViewDidTapped(_ reportTextView: ReportTextView) +} + +final class ReportTextView: UIView { + enum ReportTextViewType { + case combo + case editable + case nonEditable + } + + private enum Layout { + static let textViewHorizontalSpacing: CGFloat = 20 + static let textViewVerticalSpacing: CGFloat = 14 + static let placeholderTopSpacing: CGFloat = 15 + static let chevronImageLeadingSpacing: CGFloat = 17 + static let chevronImageTrailingSpacing: CGFloat = 19 + static let chevronImageWidth: CGFloat = 10 + static let chevronImageHeight: CGFloat = 6 + } + + private let placeholderLabel = UILabel() + private let textView = UITextView() + private let chevronImage = UIImageView() + private let button = UIButton() + weak var delegate: ReportTextViewDelegate? + + init(type: ReportTextViewType, placeholder: String?) { + super.init(frame: .zero) + configureAttribute(type: type, placeholder: placeholder) + configureLayout(type: type) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute(type: ReportTextViewType, placeholder: String?) { + backgroundColor = BitnagilColor.gray99 + + placeholderLabel.textColor = BitnagilColor.gray80 + placeholderLabel.font = BitnagilFont.init( + style: .body2, + weight: .medium + ).font + + chevronImage.image = BitnagilIcon + .chevronIcon(direction: .down)? + .withRenderingMode(.alwaysTemplate) + chevronImage.tintColor = BitnagilColor.gray10 + + button.backgroundColor = .clear + button.addAction( + UIAction { [weak self] _ in + guard let self else { return } + self.delegate?.reportTextViewDidTapped(self) + }, + for: .touchUpInside) + + textView.delegate = self + textView.font = BitnagilFont.init( + style: .body2, + weight: .medium + ).font + textView.textColor = BitnagilColor.gray10 + textView.backgroundColor = .clear + textView.textContainerInset = UIEdgeInsets(top: 1, left: 0, bottom: 0, right: 0) + textView.textContainer.lineFragmentPadding = 0 + + if type != .editable { + textView.isEditable = false + } + + if let placeholder { + placeholderLabel.text = placeholder + } + } + + private func configureLayout(type: ReportTextViewType) { + addSubview(placeholderLabel) + addSubview(textView) + + if type == .combo { + addSubview(chevronImage) + addSubview(button) + + chevronImage.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().inset(Layout.chevronImageTrailingSpacing) + make.width.equalTo(Layout.chevronImageWidth) + make.height.equalTo(Layout.chevronImageHeight) + } + + button.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + textView.snp.makeConstraints { make in + make.verticalEdges + .equalToSuperview() + .inset(Layout.textViewVerticalSpacing) + + make.leading + .equalToSuperview() + .offset(Layout.textViewHorizontalSpacing) + + make.trailing + .equalTo(chevronImage.snp.leading) + .inset(Layout.chevronImageLeadingSpacing) + } + } else { + textView.snp.makeConstraints { make in + make.verticalEdges + .equalToSuperview() + .inset(Layout.textViewVerticalSpacing) + + make.horizontalEdges + .equalToSuperview() + .inset(Layout.textViewHorizontalSpacing) + } + } + + placeholderLabel.snp.makeConstraints { make in + make.horizontalEdges + .equalTo(textView) + + make.top + .equalToSuperview() + .offset(Layout.placeholderTopSpacing) + } + } + + func configure(text: String) { + textView.text = text + updatePlaceholderVisibility() + } + + private func updatePlaceholderVisibility() { + placeholderLabel.isHidden = !(textView.text?.isEmpty ?? true) + } +} + +extension ReportTextView: UITextViewDelegate { + func textViewDidBeginEditing(_ textView: UITextView) { + updatePlaceholderVisibility() + } + + func textViewDidChange(_ textView: UITextView) { + updatePlaceholderVisibility() + } + + func textViewDidEndEditing(_ textView: UITextView) { + updatePlaceholderVisibility() + delegate?.reportTextViewDidChanged(self, text: textView.text) + } +} diff --git a/Projects/Presentation/Sources/Report/View/ReportViewController.swift b/Projects/Presentation/Sources/Report/View/ReportViewController.swift new file mode 100644 index 00000000..cd90392d --- /dev/null +++ b/Projects/Presentation/Sources/Report/View/ReportViewController.swift @@ -0,0 +1,541 @@ +// +// ReportViewController.swift +// Presentation +// +// Created by 이동현 on 11/8/25. +// + +import Combine +import Domain +import PhotosUI +import SnapKit +import UIKit + +final class ReportViewController: BaseViewController { + private enum CollectionViewSection { case main } + + private enum Layout { + static let horizontalInset: CGFloat = 20 + static let navigationBarBottomSpacing: CGFloat = 80 + static let titleLabelTopSpacing: CGFloat = 28 + static let titleLabelBottomSpacing: CGFloat = 8 + static let titleLabelHeight: CGFloat = 24 + static let cameraButtonTopSpacing: CGFloat = 8 + static let cameraButtonSize: CGFloat = 64 + static let cameraButtonCornerRadius: CGFloat = 8 + static let reportPhotoCollectionViewTopSpacing: CGFloat = 1 + static let reportCollectionLeadingSpacing: CGFloat = 14 + static let reportCollectionViewCellSize: CGFloat = 72 + static let reportCollectionViewCellSpacing: CGFloat = 7 + static let reportCollectionViewHeight: CGFloat = 72 + static let inputViewCornerRadius: CGFloat = 12 + static let textViewHeight: CGFloat = 52 + static let reportContentTextViewHeight: CGFloat = 88 + static let locationButtonSize: CGFloat = 52 + static let locationButtonLeadingSpacing: CGFloat = 12 + static let locationButtonCornerRadius: CGFloat = 12 + static let registerButtonCornerRadius: CGFloat = 12 + static let registerButtonTopSpacing: CGFloat = 47 + static let registerButtonBottomSpacing: CGFloat = 14 + static let registerButtonHeight: CGFloat = 54 + static let categoryBottomSheetHeight: CGFloat = 226 + static let cameraBottomSheetHeight: CGFloat = 174 + } + + private typealias Section = ReportViewController.CollectionViewSection + private typealias DataSource = UICollectionViewDiffableDataSource + private typealias Snapshot = NSDiffableDataSourceSnapshot + + private let scrollView = UIScrollView() + private let scrollContentView = UIView() + private let collectionViewTitleLabel = RequiredTitleLabel(title: "제보사진 등록") + private let categoryTitleLabel = RequiredTitleLabel(title: "제보 카테고리") + private let contentTitleLabel = RequiredTitleLabel(title: "제보 내용") + private let locationTitleLabel = RequiredTitleLabel(title: "내 위치 등록") + private let cameraButton = ReportCameraButton(frame: .zero) + private let photoCollectionView = UICollectionView(frame: .zero, collectionViewLayout: .init()) + private let categoryTextView = ReportTextView(type: .combo, placeholder: "카테고리 선택") + private let reportTitleTextView = ReportTextView(type: .editable, placeholder: "제보 제목을 작성해주세요.") + private let reportContentTextView = ReportTextView(type: .editable, placeholder: "어떤 위험인지 간단히 설명해주세요.(100자 내외)") + private let locationTextView = ReportTextView(type: .nonEditable, placeholder: "현재 위치 검색") + private let locationButton = LocationButton() + private let registerButton = PrimaryButton(buttonState: .disabled, buttonTitle: "제출하기") + private let photoSelectionView = SelectableItemTableView(items: SelectPhotoType.allCases.sorted(by: { $0.id < $1.id }), markIsSelected: false) + private let categorySelectionView = SelectableItemTableView(items: ReportType.allCases.sorted(by: { $0.id < $1.id})) + private var cancellables: Set = [] + private var dataSource: DataSource? + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureCustomNavigationBar(navigationBarStyle: .withBackButton(title: "제보하기")) + } + + override func configureAttribute() { + configureCollectionViewDataSource() + configurePhotoCollectionLayout() + + view.backgroundColor = .white + + scrollView.showsVerticalScrollIndicator = false + photoCollectionView.delegate = self + + [ + categoryTextView, + locationTextView, + reportTitleTextView, + reportContentTextView].forEach { textView in + textView.layer.cornerRadius = Layout.inputViewCornerRadius + textView.layer.masksToBounds = true + textView.delegate = self + } + + cameraButton.layer.cornerRadius = Layout.locationButtonCornerRadius + cameraButton.layer.masksToBounds = true + cameraButton.addAction( + UIAction { [weak self] _ in + self?.showCameraBottomSheet() + }, + for: .touchUpInside) + + locationButton.layer.cornerRadius = Layout.locationButtonCornerRadius + locationButton.layer.masksToBounds = true + + registerButton.layer.cornerRadius = Layout.registerButtonCornerRadius + registerButton.layer.masksToBounds = true + registerButton.addAction( + UIAction { [weak self] _ in + self?.viewModel.action(input: .configureLocation) + }, + for: .touchUpInside) + + photoSelectionView.delegate = self + categorySelectionView.delegate = self + applySnapshot(items: [], animating: false) + } + + override func configureLayout() { + let safeArea = view.safeAreaLayoutGuide + view.addSubview(scrollView) + scrollView.addSubview(scrollContentView) + scrollContentView.addSubview(collectionViewTitleLabel) + scrollContentView.addSubview(categoryTitleLabel) + scrollContentView.addSubview(contentTitleLabel) + scrollContentView.addSubview(locationTitleLabel) + scrollContentView.addSubview(cameraButton) + scrollContentView.addSubview(photoCollectionView) + scrollContentView.addSubview(categoryTextView) + scrollContentView.addSubview(reportTitleTextView) + scrollContentView.addSubview(reportContentTextView) + scrollContentView.addSubview(locationTextView) + scrollContentView.addSubview(locationButton) + scrollContentView.addSubview(registerButton) + + scrollView.snp.makeConstraints { make in + make.edges.equalTo(safeArea) + } + + scrollContentView.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide) + make.width.equalTo(scrollView.frameLayoutGuide) + make.height.equalTo(scrollView.frameLayoutGuide).priority(.low) + } + + collectionViewTitleLabel.snp.makeConstraints { make in + make.top + .equalToSuperview() + .offset(Layout.navigationBarBottomSpacing) + + make.leading + .equalToSuperview() + .offset(Layout.horizontalInset) + + make.height + .equalTo(Layout.titleLabelHeight) + } + + cameraButton.snp.makeConstraints { make in + make.top + .equalTo(collectionViewTitleLabel.snp.bottom) + .offset(Layout.cameraButtonTopSpacing) + + make.leading + .equalToSuperview() + .offset(Layout.horizontalInset) + + make.size.equalTo(Layout.cameraButtonSize) + } + + photoCollectionView.snp.makeConstraints { make in + make.top + .equalTo(collectionViewTitleLabel.snp.bottom) + .offset(Layout.reportPhotoCollectionViewTopSpacing) + + make.leading + .equalTo(cameraButton.snp.trailing) + .offset(Layout.reportCollectionLeadingSpacing) + + make.trailing + .equalToSuperview() + .inset(Layout.horizontalInset) + + make.height + .equalTo(Layout.reportCollectionViewHeight) + } + + categoryTitleLabel.snp.makeConstraints { make in + make.top + .equalTo(cameraButton.snp.bottom) + .offset(Layout.titleLabelTopSpacing) + + make.leading + .equalToSuperview() + .offset(Layout.horizontalInset) + + make.height + .equalTo(Layout.titleLabelHeight) + } + + categoryTextView.snp.makeConstraints { make in + make.top + .equalTo(categoryTitleLabel.snp.bottom) + .offset(Layout.titleLabelBottomSpacing) + + make.horizontalEdges + .equalToSuperview() + .inset(Layout.horizontalInset) + + make.height + .equalTo(Layout.textViewHeight) + } + + contentTitleLabel.snp.makeConstraints { make in + make.top + .equalTo(categoryTextView.snp.bottom) + .offset(Layout.titleLabelTopSpacing) + + make.leading + .equalToSuperview() + .offset(Layout.horizontalInset) + + make.height + .equalTo(Layout.titleLabelHeight) + } + + reportTitleTextView.snp.makeConstraints { make in + make.top + .equalTo(contentTitleLabel.snp.bottom) + .offset(Layout.titleLabelBottomSpacing) + + make.horizontalEdges + .equalToSuperview() + .inset(Layout.horizontalInset) + + make.height + .equalTo(Layout.textViewHeight) + } + + reportContentTextView.snp.makeConstraints { make in + make.top + .equalTo(reportTitleTextView.snp.bottom) + .offset(Layout.titleLabelBottomSpacing) + + make.horizontalEdges + .equalToSuperview() + .inset(Layout.horizontalInset) + + make.height + .equalTo(Layout.reportContentTextViewHeight) + .priority(.medium) + } + + locationTitleLabel.snp.makeConstraints { make in + make.top + .equalTo(reportContentTextView.snp.bottom) + .offset(Layout.titleLabelTopSpacing) + + make.leading + .equalToSuperview() + .offset(Layout.horizontalInset) + + make.height + .equalTo(Layout.titleLabelHeight) + } + + locationButton.snp.makeConstraints { make in + make.top + .equalTo(locationTitleLabel.snp.bottom) + .offset(Layout.titleLabelBottomSpacing) + + make.trailing + .equalToSuperview() + .inset(Layout.horizontalInset) + + make.size + .equalTo(Layout.locationButtonSize) + } + + locationTextView.snp.makeConstraints { make in + make.leading + .equalToSuperview() + .offset(Layout.horizontalInset) + + make.trailing + .equalTo(locationButton.snp.leading) + .offset(-Layout.locationButtonLeadingSpacing) + + make.top + .equalTo(locationButton) + + make.height + .equalTo(Layout.textViewHeight) + } + + registerButton.snp.makeConstraints { make in + make.horizontalEdges + .equalToSuperview() + .inset(Layout.horizontalInset) + + make.top + .greaterThanOrEqualTo(locationTextView.snp.bottom) + .offset(Layout.registerButtonTopSpacing) + + make.bottom + .equalToSuperview() + .inset(Layout.registerButtonBottomSpacing) + + make.height + .equalTo(Layout.registerButtonHeight) + } + } + + override func bind() { + viewModel.output.categoryPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] category in + self?.categoryTextView.configure(text: category ?? "") + } + .store(in: &cancellables) + + viewModel.output.titlePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + self?.reportTitleTextView.configure(text: title ?? "") + } + .store(in: &cancellables) + + viewModel.output.contentPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] content in + self?.reportContentTextView.configure(text: content ?? "") + } + .store(in: &cancellables) + + viewModel.output.locationPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] address in + self?.locationTextView.configure(text: address ?? "") + } + .store(in: &cancellables) + + viewModel.output.exceptionPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] message in + let alertController = UIAlertController(title: "알림", message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: "확인", style: .default)) + self?.present(alertController, animated: true) + } + .store(in: &cancellables) + + viewModel.output.selectedPhotoPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] items in + guard let self else { return } + self.applySnapshot(items: items, animating: true) + self.cameraButton + .configure(imageCount: items.count, maxCount: viewModel.output.maxPhotoCount) + } + .store(in: &cancellables) + } + + private func showCameraBottomSheet() { + presentCustomBottomSheet(contentViewController: photoSelectionView, maxHeight: Layout.cameraBottomSheetHeight) + } + + private func showCategoryBottomSheet() { + presentCustomBottomSheet(contentViewController: categorySelectionView, maxHeight: Layout.categoryBottomSheetHeight ) + } + + private func configureCollectionViewDataSource() { + let registration = UICollectionView.CellRegistration { cell, _, item in + cell.configure(item: item) + cell.delegate = self + } + + dataSource = DataSource(collectionView: photoCollectionView) { collectionView, indexPath, item in + collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: item) + } + photoCollectionView.dataSource = dataSource + } + + private func configurePhotoCollectionLayout() { + let itemSize = NSCollectionLayoutSize( + widthDimension: .absolute(Layout.reportCollectionViewCellSize), + heightDimension: .absolute(Layout.reportCollectionViewCellSize) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .estimated(Layout.reportCollectionViewCellSize), + heightDimension: .estimated(Layout.reportCollectionViewCellSize) + ) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + group.interItemSpacing = .fixed(Layout.reportCollectionViewCellSpacing) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = .zero + section.interGroupSpacing = .zero + section.orthogonalScrollingBehavior = .continuous + + let layout = UICollectionViewCompositionalLayout(section: section) + photoCollectionView.setCollectionViewLayout(layout, animated: false) + } + + private func applySnapshot(items: [PhotoItem], animating: Bool) { + var snapshot = Snapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(items, toSection: .main) + dataSource?.apply(snapshot, animatingDifferences: true) + } + + private func presentCamera() { + guard UIImagePickerController.isSourceTypeAvailable(.camera) else { + let alertController = UIAlertController(title: "카메라 사용 불가", message: "이 기기에서 카메라를 사용할 수 없습니다.", preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: "확인", style: .default)) + present(alertController, animated: true) + return + } + + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.allowsEditing = false + picker.delegate = self + present(picker, animated: true) + } + + private func presentPhotoPicker() { + var config = PHPickerConfiguration(photoLibrary: .shared()) + config.filter = .images + config.selectionLimit = 3 + let picker = PHPickerViewController(configuration: config) + picker.delegate = self + present(picker, animated: true) + } + + private func dismissIfNeededThen(_ action: @escaping () -> Void) { + if let presentedViewController { + + } + + if Thread.isMainThread { + if let presented = presentedViewController { + presented.dismiss(animated: true, completion: action) + } else { + action() + } + } else { + DispatchQueue.main.async { [weak self] in + self?.dismissIfNeededThen(action) + } + } + } +} + +extension ReportViewController: ReportTextViewDelegate { + func reportTextViewDidChanged(_ reportTextView: ReportTextView, text: String?) { + switch reportTextView { + case reportTitleTextView: + viewModel.action(input: .inputTitle(title: text)) + case reportContentTextView: + viewModel.action(input: .inputContent(content: text)) + default: return + } + } + + func reportTextViewDidTapped(_ reportTextView: ReportTextView) { + showCategoryBottomSheet() + } +} + +extension ReportViewController: ReportPhotoCollectionViewCellDelegate { + func reportPhotoCollectionViewCellWillDeleteCell(_ cell: ReportPhotoCollectionViewCell, uuid: UUID) { + viewModel.action(input: .removePhoto(id: uuid)) + } +} + +extension ReportViewController: UICollectionViewDelegate { + +} + +extension ReportViewController: SelectableItemTableViewDelegate { + func selectableItemTableView(_ sender: SelectableItemTableView, didSelectItem: T?) where T : SelectableItem, T : CaseIterable, T : Equatable { + if + T.self == ReportType.self, + let item = didSelectItem as? ReportType + { + viewModel.action(input: .selectCategory(type: item)) + + } else if + T.self == SelectPhotoType.self, + let item = didSelectItem as? SelectPhotoType + { + switch item { + case .camera: + dismissIfNeededThen { [weak self] in + self?.presentCamera() + } + case .library: + dismissIfNeededThen { [weak self] in + self?.presentPhotoPicker() + } + } + } + } +} + +extension ReportViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + defer { picker.dismiss(animated: true) } + + guard + let image = info[.originalImage] as? UIImage, + let data = image.jpegData(compressionQuality: 0.5) + else { return } + + viewModel.action(input: .selectPhoto(photoData: data)) + } +} + +extension ReportViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true, completion: nil) + guard + let itemProvider = results.first?.itemProvider, + itemProvider.canLoadObject(ofClass: UIImage.self) + else { return } + + itemProvider.loadObject(ofClass: UIImage.self) { image, _ in + guard + let selectedImage = image as? UIImage, + let imageData = selectedImage.jpegData(compressionQuality: 0.5) + else { return } + + DispatchQueue.main.async { + self.viewModel.action(input: .selectPhoto(photoData: imageData)) + } + } + } +} diff --git a/Projects/Presentation/Sources/Report/ViewModel/ReportViewModel.swift b/Projects/Presentation/Sources/Report/ViewModel/ReportViewModel.swift new file mode 100644 index 00000000..1432a9c4 --- /dev/null +++ b/Projects/Presentation/Sources/Report/ViewModel/ReportViewModel.swift @@ -0,0 +1,106 @@ +// +// ReportViewModel.swift +// Presentation +// +// Created by 이동현 on 11/9/25. +// + +import Combine +import Domain +import Foundation + +final class ReportViewModel: ViewModel { + enum Input { + case selectCategory(type: ReportType) + case inputTitle(title: String?) + case inputContent(content: String?) + case configureLocation + case selectPhoto(photoData: Data) + case removePhoto(id: UUID) + } + + struct Output { + let categoryPublisher: AnyPublisher + let titlePublisher: AnyPublisher + let contentPublisher: AnyPublisher + let locationPublisher: AnyPublisher + let selectedPhotoPublisher: AnyPublisher<[PhotoItem], Never> + let exceptionPublisher: AnyPublisher + let maxPhotoCount: Int + } + + private(set) var output: Output + private let categorySubject = CurrentValueSubject(nil) + private let titleSubject = CurrentValueSubject(nil) + private let contentSubject = CurrentValueSubject(nil) + private let locationSubject = CurrentValueSubject(nil) + private let selectedPhotoSubject = CurrentValueSubject<[PhotoItem], Never>([]) + private let exceptionSubject = PassthroughSubject() + private let maxPhotoCount = 3 + private var latitude: Double? = nil + private var longitude: Double? = nil + + init() { + self.output = Output( + categoryPublisher: categorySubject.map { $0?.description }.eraseToAnyPublisher(), + titlePublisher: titleSubject.eraseToAnyPublisher(), + contentPublisher: contentSubject.eraseToAnyPublisher(), + locationPublisher: locationSubject.eraseToAnyPublisher(), + selectedPhotoPublisher: selectedPhotoSubject.eraseToAnyPublisher(), + exceptionPublisher: exceptionSubject.eraseToAnyPublisher(), + maxPhotoCount: maxPhotoCount) + } + + func action(input: Input) { + switch input { + case .selectCategory(let type): + configureCategory(type: type) + case .inputTitle(let title): + configureTitle(title: title) + case .inputContent(let content): + configureContent(content: content) + case .configureLocation: + configureLocation() + case .selectPhoto(let photoData): + selectPhoto(photoData: photoData) + case .removePhoto(let id): + removePhoto(id: id) + } + } + + private func configureCategory(type: ReportType?) { + categorySubject.send(type) + } + + private func configureTitle(title: String?) { + titleSubject.send(title) + } + + private func configureContent(content: String?) { + contentSubject.send(content) + } + + private func configureLocation() { + // 카카오 sdk로 현 위치 설정 + } + + private func selectPhoto(photoData: Data?) { + guard let photoData else { return } + var currentSelectedPhoto = selectedPhotoSubject.value + + guard currentSelectedPhoto.count < maxPhotoCount else { + exceptionSubject.send("사진은 최대 \(maxPhotoCount)장까지 선택할 수 있습니다.") + return + } + + let item = PhotoItem(data: photoData) + currentSelectedPhoto.append(item) + + selectedPhotoSubject.send(currentSelectedPhoto) + } + + private func removePhoto(id: UUID) { + let currentSelectedPhoto = selectedPhotoSubject.value.filter { $0.id != id } + selectedPhotoSubject.send(currentSelectedPhoto) + } +} diff --git a/SupportingFiles/Info.plist b/SupportingFiles/Info.plist index 6b2e0bfe..1a1e8eb7 100644 --- a/SupportingFiles/Info.plist +++ b/SupportingFiles/Info.plist @@ -2,6 +2,10 @@ + NSPhotoLibraryUsageDescription + 앨범에서 사진을 선택합니다. 허용하시겠습니까? + NSCameraUsageDescription + 카메라를 사용해 사진을 촬영합니다. 허용하시겠습니까? BaseURL $(BASE_URL) CFBundleExecutable