Skip to content

Commit 671a121

Browse files
committed
feat: 기록 UI, Logic 1차 개발 완료
1 parent bb25409 commit 671a121

2 files changed

Lines changed: 257 additions & 42 deletions

File tree

Projects/Feature/Feed/Sources/Presentation/Record/FeedRecordReactor.swift

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,147 @@
55
// Created by 김동준 on 7/12/26.
66
//
77

8-
import ReactorKit
8+
import Foundation
99
import FeedInterface
10+
import ReactorKit
1011

1112
public final class FeedRecordReactor: Reactor {
1213
public let initialState: State
1314
private weak var router: FeedRecordRouter?
1415

1516
public struct State {
1617
let recordType: FeedRecordType
18+
var mealMenu = ""
19+
var mealTime: Date
20+
var selectedExerciseType: FeedExerciseType?
21+
var customExerciseName = ""
22+
var exerciseHours = 2
23+
var exerciseMinutes = 0
24+
var isExerciseMenuExpanded = false
25+
var isPhotoSourceSheetPresented = false
26+
27+
var isFinishButtonEnabled: Bool {
28+
switch recordType {
29+
case .meal:
30+
return !mealMenu.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
31+
case .exercise:
32+
guard let selectedExerciseType else { return false }
33+
let hasValidExercise = selectedExerciseType != .custom
34+
|| !customExerciseName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
35+
let hasValidDuration = exerciseHours > 0 || exerciseMinutes > 0
36+
return hasValidExercise && hasValidDuration
37+
}
38+
}
1739
}
1840

1941
public enum Action {
2042
case didTapBackButton
43+
case didTapPhotoUpload
44+
case didDismissPhotoSourceSheet
45+
case didTapCamera
46+
case didTapGallery
47+
case didChangeMealMenu(String)
48+
case didChangeMealTime(Date)
49+
case didTapExerciseMenu
50+
case didSelectExerciseType(FeedExerciseType)
51+
case didChangeCustomExerciseName(String)
52+
case didChangeExerciseDuration(hours: Int, minutes: Int)
53+
case didTapFinishButton
2154
}
2255

23-
public enum Mutation {}
56+
public enum Mutation {
57+
case setPhotoSourceSheetPresented(Bool)
58+
case setMealMenu(String)
59+
case setMealTime(Date)
60+
case setExerciseMenuExpanded(Bool)
61+
case setExerciseType(FeedExerciseType)
62+
case setCustomExerciseName(String)
63+
case setExerciseDuration(hours: Int, minutes: Int)
64+
}
2465

2566
public init(
2667
router: FeedRecordRouter,
2768
recordType: FeedRecordType
2869
) {
2970
self.router = router
30-
self.initialState = State(recordType: recordType)
71+
self.initialState = State(
72+
recordType: recordType,
73+
mealTime: Self.makeInitialMealTime()
74+
)
3175
}
3276

3377
public func mutate(action: Action) -> Observable<Mutation> {
3478
switch action {
3579
case .didTapBackButton:
3680
return routeToBack()
81+
case .didTapPhotoUpload:
82+
print("[FeedRecordReactor] 사진 업로드하기")
83+
return .just(.setPhotoSourceSheetPresented(true))
84+
case .didDismissPhotoSourceSheet:
85+
return .just(.setPhotoSourceSheetPresented(false))
86+
case .didTapCamera:
87+
print("[FeedRecordReactor] 사진 촬영하기")
88+
return .just(.setPhotoSourceSheetPresented(false))
89+
case .didTapGallery:
90+
print("[FeedRecordReactor] 갤러리에서 선택하기")
91+
return .just(.setPhotoSourceSheetPresented(false))
92+
case let .didChangeMealMenu(menu):
93+
return .just(.setMealMenu(menu))
94+
case let .didChangeMealTime(date):
95+
return .just(.setMealTime(date))
96+
case .didTapExerciseMenu:
97+
return .just(.setExerciseMenuExpanded(!currentState.isExerciseMenuExpanded))
98+
case let .didSelectExerciseType(type):
99+
return .just(.setExerciseType(type))
100+
case let .didChangeCustomExerciseName(name):
101+
return .just(.setCustomExerciseName(name))
102+
case let .didChangeExerciseDuration(hours, minutes):
103+
return .just(.setExerciseDuration(hours: hours, minutes: minutes))
104+
case .didTapFinishButton:
105+
guard currentState.isFinishButtonEnabled else { return .empty() }
106+
print("[FeedRecordReactor] 작성 완료")
107+
return .empty()
37108
}
38109
}
110+
111+
public func reduce(state: State, mutation: Mutation) -> State {
112+
var newState = state
113+
114+
switch mutation {
115+
case let .setPhotoSourceSheetPresented(isPresented):
116+
newState.isPhotoSourceSheetPresented = isPresented
117+
case let .setMealMenu(menu):
118+
newState.mealMenu = menu
119+
case let .setMealTime(date):
120+
newState.mealTime = date
121+
case let .setExerciseMenuExpanded(isExpanded):
122+
newState.isExerciseMenuExpanded = isExpanded
123+
case let .setExerciseType(type):
124+
newState.selectedExerciseType = type
125+
newState.isExerciseMenuExpanded = false
126+
case let .setCustomExerciseName(name):
127+
newState.customExerciseName = name
128+
case let .setExerciseDuration(hours, minutes):
129+
newState.exerciseHours = hours
130+
newState.exerciseMinutes = minutes
131+
}
132+
133+
return newState
134+
}
39135
}
40136

41137
private extension FeedRecordReactor {
138+
static func makeInitialMealTime() -> Date {
139+
var calendar = Calendar(identifier: .gregorian)
140+
calendar.timeZone = TimeZone(identifier: "Asia/Seoul") ?? .current
141+
return calendar.date(
142+
bySettingHour: 9,
143+
minute: 0,
144+
second: 0,
145+
of: Date()
146+
) ?? Date()
147+
}
148+
42149
func routeToBack() -> Observable<Mutation> {
43150
return .deferred { [weak router] in
44151
Task { @MainActor in

Projects/Feature/Feed/Sources/Presentation/Record/FeedRecordViewController.swift

Lines changed: 147 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,25 @@
77

88
import UIKit
99
import SwiftUI
10+
import Base
1011
import FeedInterface
1112
import ReactorKit
1213
import DesignSystem
13-
import SnapKit
14+
import RxCocoa
1415

1516
public final class FeedRecordViewController: UIViewController, ReactorKit.View {
1617
public var disposeBag = DisposeBag()
1718

18-
private let navigationBarContainerView = UIView()
19-
private let contentLabel = MUILabel(
20-
style: .b3,
21-
color: .gray10
22-
)
19+
private let recordType: FeedRecordType
20+
private let recordView: FeedRecordView
21+
22+
private var finishButtonHostingController: UIHostingController<FeedRecordFinishButtonView>?
23+
var photoSourceSheetViewController: UIViewController?
2324

2425
public init(reactor: FeedRecordReactor) {
26+
let recordType = reactor.initialState.recordType
27+
self.recordType = recordType
28+
self.recordView = FeedRecordView(recordType: recordType)
2529
defer { self.reactor = reactor }
2630
super.init(nibName: nil, bundle: nil)
2731
}
@@ -31,67 +35,171 @@ public final class FeedRecordViewController: UIViewController, ReactorKit.View {
3135
fatalError("init(coder:) has not been implemented")
3236
}
3337

38+
public override func loadView() {
39+
view = recordView
40+
}
41+
3442
public override func viewDidLoad() {
3543
super.viewDidLoad()
3644

37-
setupUI()
38-
setupLayout()
3945
configureNavigationBar()
46+
configureRecordTypeContent()
4047
}
4148

4249
public func bind(reactor: FeedRecordReactor) {
50+
recordView.uploadView.rx.controlEvent(.touchUpInside)
51+
.map { FeedRecordReactor.Action.didTapPhotoUpload }
52+
.bind(to: reactor.action)
53+
.disposed(by: disposeBag)
54+
55+
recordView.exerciseSelectionField.rx.controlEvent(.touchUpInside)
56+
.map { FeedRecordReactor.Action.didTapExerciseMenu }
57+
.bind(to: reactor.action)
58+
.disposed(by: disposeBag)
59+
60+
recordView.exerciseMenuView.onSelect = { [weak reactor] type in
61+
reactor?.action.onNext(.didSelectExerciseType(type))
62+
}
63+
64+
recordView.exerciseSelectionField.onCustomTextChanged = { [weak reactor] text in
65+
reactor?.action.onNext(.didChangeCustomExerciseName(text))
66+
}
67+
4368
reactor.state
44-
.map(\.recordType.title)
69+
.map(\.isPhotoSourceSheetPresented)
4570
.distinctUntilChanged()
4671
.observe(on: MainScheduler.instance)
47-
.bind(with: self) { owner, title in
48-
owner.contentLabel.text = "\(title) 화면"
72+
.bind(with: self) { owner, isPresented in
73+
owner.setPhotoSourceSheetPresented(isPresented)
4974
}
5075
.disposed(by: disposeBag)
51-
}
52-
}
5376

54-
private extension FeedRecordViewController {
55-
func setupUI() {
56-
view.backgroundColor = .systemWhite
57-
contentLabel.textAlignment = .center
58-
}
59-
60-
func setupLayout() {
61-
view.addSubview(navigationBarContainerView)
62-
view.addSubview(contentLabel)
77+
reactor.state
78+
.map(\.isFinishButtonEnabled)
79+
.distinctUntilChanged()
80+
.observe(on: MainScheduler.instance)
81+
.bind(with: self) { owner, isEnabled in
82+
owner.configureFinishButton(isEnabled: isEnabled)
83+
}
84+
.disposed(by: disposeBag)
6385

64-
navigationBarContainerView.snp.makeConstraints {
65-
$0.top.equalTo(view.safeAreaLayoutGuide)
66-
$0.leading.trailing.equalToSuperview()
67-
}
86+
guard recordType == .exercise else { return }
6887

69-
contentLabel.snp.makeConstraints {
70-
$0.center.equalToSuperview()
71-
}
88+
reactor.state
89+
.map { state in
90+
FeedRecordExerciseInputViewState(
91+
selectedType: state.selectedExerciseType,
92+
customExerciseName: state.customExerciseName,
93+
isExpanded: state.isExerciseMenuExpanded
94+
)
95+
}
96+
.distinctUntilChanged()
97+
.observe(on: MainScheduler.instance)
98+
.bind(with: self) { owner, viewState in
99+
owner.recordView.configureExerciseInput(viewState)
100+
}
101+
.disposed(by: disposeBag)
72102
}
103+
}
73104

105+
private extension FeedRecordViewController {
74106
func configureNavigationBar() {
75-
guard let recordType = reactor?.currentState.recordType else { return }
76-
77107
let navigationBar = MNavigationBar(
78108
title: recordType.title,
79109
onBackTap: { [weak self] in
80110
self?.reactor?.action.onNext(.didTapBackButton)
81111
}
82112
)
83-
let hostingController = UIHostingController(rootView: navigationBar)
113+
addHostedView(
114+
navigationBar,
115+
to: recordView.navigationBarContainerView,
116+
sizesToContent: true
117+
)
118+
}
119+
120+
func configureRecordTypeContent() {
121+
switch recordType {
122+
case .meal:
123+
configureMealTextField()
124+
configureMealTimePicker()
125+
case .exercise:
126+
configureExerciseDurationPicker()
127+
}
128+
129+
configureFinishButton(isEnabled: false)
130+
}
131+
}
84132

85-
setupNavigationBarLayout(hostingController)
86-
hostingController.didMove(toParent: self)
133+
private extension FeedRecordViewController {
134+
func configureMealTextField() {
135+
let textField = FeedRecordMealTextFieldView(
136+
text: reactor?.currentState.mealMenu ?? "",
137+
placeholder: "메뉴를 간단하게 입력해주세요",
138+
onTextChanged: { [weak self] text in
139+
self?.reactor?.action.onNext(.didChangeMealMenu(text))
140+
}
141+
)
142+
addHostedView(
143+
textField,
144+
to: recordView.inputFieldContainerView,
145+
sizesToContent: true
146+
)
87147
}
148+
149+
func configureMealTimePicker() {
150+
let timePicker = FeedRecordMealTimePickerView(
151+
date: reactor?.currentState.mealTime ?? Date(),
152+
onDateChanged: { [weak self] date in
153+
self?.reactor?.action.onNext(.didChangeMealTime(date))
154+
}
155+
)
156+
addHostedView(
157+
timePicker,
158+
to: recordView.timePickerContainerView,
159+
sizesToContent: true
160+
)
161+
}
162+
}
163+
164+
private extension FeedRecordViewController {
165+
func configureExerciseDurationPicker() {
166+
let state = reactor?.currentState
167+
let durationPicker = FeedRecordExerciseDurationPickerView(
168+
hours: state?.exerciseHours ?? 0,
169+
minutes: state?.exerciseMinutes ?? 0,
170+
onDurationChanged: { [weak self] hours, minutes in
171+
self?.reactor?.action.onNext(
172+
.didChangeExerciseDuration(hours: hours, minutes: minutes)
173+
)
174+
}
175+
)
176+
addHostedView(
177+
durationPicker,
178+
to: recordView.timePickerContainerView,
179+
sizesToContent: true
180+
)
181+
}
182+
}
88183

89-
func setupNavigationBarLayout(_ hostingController: UIHostingController<MNavigationBar>) {
90-
addChild(hostingController)
91-
navigationBarContainerView.addSubview(hostingController.view)
184+
private extension FeedRecordViewController {
185+
func configureFinishButton(isEnabled: Bool) {
186+
let rootView = FeedRecordFinishButtonView(
187+
isEnabled: isEnabled,
188+
onTap: { [weak self] in
189+
self?.reactor?.action.onNext(.didTapFinishButton)
190+
}
191+
)
92192

93-
hostingController.view.snp.makeConstraints {
94-
$0.edges.equalToSuperview()
193+
if let finishButtonHostingController {
194+
finishButtonHostingController.rootView = rootView
195+
return
95196
}
197+
198+
let hostingController = addHostedView(
199+
rootView,
200+
to: recordView.finishButtonContainerView,
201+
sizesToContent: true
202+
)
203+
finishButtonHostingController = hostingController
96204
}
97205
}

0 commit comments

Comments
 (0)