Skip to content

Commit

Permalink
Merge pull request #73 from YAPP-Github/TNT-227-traineeDietPageScreen
Browse files Browse the repository at this point in the history
[TNT-227] ํŠธ๋ ˆ์ด๋‹ˆ ์‹๋‹จ ๊ธฐ๋ก ํ™”๋ฉด ์ž‘์„ฑ
  • Loading branch information
FpRaArNkK authored Feb 12, 2025
2 parents b6e1031 + c02c951 commit bfb1af4
Show file tree
Hide file tree
Showing 12 changed files with 672 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public struct TTextEditor: View {
/// TextEditor ์ˆ˜์ง ํŒจ๋”ฉ ๊ฐ’
private static let verticalPadding: CGFloat = 12
/// TextEditor ๊ธฐ๋ณธ ๋†’์ด๊ฐ’
public static let defaultHeight: CGFloat = 130
public static let defaultHeight: CGFloat = 40

/// ํ•˜๋‹จ์— ํ‘œ์‹œ๋˜๋Š” ํ‘ธํ„ฐ ๋ทฐ
private let footer: Footer?
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// MealType.swift
// DietType.swift
// Domain
//
// Created by ๋ฐ•๋ฏผ์„œ on 1/29/25.
Expand All @@ -9,14 +9,14 @@
import Foundation

/// ์•ฑ์—์„œ ์กด์žฌํ•˜๋Š” ์‹๋‹จ ์œ ํ˜•์„ ์ •์˜ํ•œ ์—ด๊ฑฐํ˜•
public enum MealType: Sendable {
public enum DietType: Sendable, CaseIterable {
case morning
case lunch
case dinner
case snack

/// ์‹์‚ฌ ์œ ํ˜•์„ ํ•œ๊ธ€๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋ฐ˜ํ™˜
var koreanName: String {
public var koreanName: String {
switch self {
case .morning: return "์•„์นจ"
case .lunch: return "์ ์‹ฌ"
Expand Down
4 changes: 2 additions & 2 deletions TnT/Projects/Domain/Sources/Entity/RecordType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public enum RecordType: Sendable, Equatable {
/// ์šด๋™
case workout(type: WorkoutType)
/// ์‹๋‹จ
case meal(type: MealType)
case diet(type: DietType)
}

public extension RecordType {
Expand All @@ -24,7 +24,7 @@ public extension RecordType {
return "\(count)ํšŒ์ฐจ ์ˆ˜์—…"
case .workout(let type):
return type.koreanName
case .meal(let type):
case .diet(let type):
return type.koreanName
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
//
// TraineeAddDietRecordFeature.swift
// Presentation
//
// Created by ๋ฐ•๋ฏผ์„œ on 2/10/25.
// Copyright ยฉ 2025 yapp25thTeamTnT. All rights reserved.
//

import Foundation
import _PhotosUI_SwiftUI
import ComposableArchitecture

import Domain
import DesignSystem

@Reducer
public struct TraineeAddDietRecordFeature {

public typealias FocusField = TraineeAddDietRecordView.Field

@ObservableState
public struct State: Equatable {
// MARK: Data related state
/// ์‹๋‹จ ๋‚ ์งœ
var dietDate: Date?
/// ์‹๋‹จ ์‹œ๊ฐ„
var dietTime: Date?
/// ์‹๋‹จ ํƒ€์ž…
var dietType: DietType?
/// ์‹๋‹จ ์‚ฌ์ง„
var dietImageData: Data?
/// ์‹๋‹จ ์ •๋ณด
var dietInfo: String

// MARK: UI related state
/// ํ…์ŠคํŠธ ํ•„๋“œ ์ƒํƒœ (๋นˆ ๊ฐ’ / ์ž…๋ ฅ๋จ / ์œ ํšจํ•˜์ง€ ์•Š์Œ)
var view_dietDateStatus: TTextField.Status
var view_dietTimeStatus: TTextField.Status
var view_dietInfoStatus: TTextEditor.Status
/// ํ˜„์žฌ ํฌ์ปค์Šค๋œ ํ•„๋“œ
var view_focusField: FocusField?
/// BottomSheet์— ํ‘œ์‹œํ•  ์•„์ดํ…œ
var view_bottomSheetItem: BottomSheetItem?
/// "์™„๋ฃŒ" ๋ฒ„ํŠผ ํ™œ์„ฑํ™” ์—ฌ๋ถ€
var view_isSubmitButtonEnabled: Bool
/// ํ˜„์žฌ ์„ ํƒ๋œ ์ด๋ฏธ์ง€ (PhotosPickerItem)
var view_photoPickerItem: PhotosPickerItem?
/// ํ‘œ์‹œ๋˜๋Š” ํŒ์—…
var view_popUp: PopUp?
/// ํŒ์—… ํ‘œ์‹œ ์—ฌ๋ถ€
var view_isPopUpPresented: Bool

public init(
dietDate: Date? = nil,
dietTime: Date? = nil,
dietType: DietType? = nil,
dietImageData: Data? = nil,
dietInfo: String = "",
view_dietDateStatus: TTextField.Status = .empty,
view_dietTimeStatus: TTextField.Status = .empty,
view_dietInfoStatus: TTextEditor.Status = .empty,
view_focusField: FocusField? = nil,
view_bottomSheetItem: BottomSheetItem? = nil,
view_isSubmitButtonEnabled: Bool = false,
view_photoPickerItem: PhotosPickerItem? = nil,
view_popUp: PopUp? = nil,
view_isPopUpPresented: Bool = false
) {
self.dietDate = dietDate
self.dietTime = dietTime
self.dietType = dietType
self.dietImageData = dietImageData
self.dietInfo = dietInfo
self.view_dietDateStatus = view_dietDateStatus
self.view_dietTimeStatus = view_dietTimeStatus
self.view_dietInfoStatus = view_dietInfoStatus
self.view_focusField = view_focusField
self.view_bottomSheetItem = view_bottomSheetItem
self.view_isSubmitButtonEnabled = view_isSubmitButtonEnabled
self.view_photoPickerItem = view_photoPickerItem
self.view_popUp = view_popUp
self.view_isPopUpPresented = view_isPopUpPresented
}
}

@Dependency(\.dismiss) private var dismiss

public enum Action: Sendable, ViewAction {
/// ๋ทฐ์—์„œ ๋ฐœ์ƒํ•œ ์•ก์…˜์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
case view(View)
/// ์„ ํƒ๋œ ์ด๋ฏธ์ง€ ๋ฐ์ดํ„ฐ ์ €์žฅ
case imagePicked(Data?)
/// ๋„ค๋น„๊ฒŒ์ด์…˜ ์—ฌ๋ถ€ ์„ค์ •
case setNavigating

@CasePathable
public enum View: Sendable, BindableAction {
/// ๋ฐ”์ธ๋”ฉํ•  ์•ก์…˜์„ ์ฒ˜๋ฆฌ
case binding(BindingAction<State>)
/// ๋„ค๋น„๋ฐ” ๋ฐฑ๋ฒ„ํŠผ ํƒญ๋˜์—ˆ์„ ๋•Œ
case tapNavBackButton
/// ์ด๋ฏธ์ง€ ํ”ผ์ปค ์‚ญ์ œ ๋ฒ„ํŠผ ํƒญ๋˜์—ˆ์„ ๋•Œ
case tapPhotoPickerDeleteButton
/// ์‹๋‹จ ๋‚ ์งœ ๋“œ๋กญ๋‹ค์šด์ด ํƒญ๋˜์—ˆ์„ ๋•Œ (DatePicker ํ‘œ์‹œ)
case tapDietDateDropDown
/// ์‹๋‹จ ์‹œ๊ฐ„ ๋“œ๋กญ๋‹ค์šด์ด ํƒญ๋˜์—ˆ์„ ๋•Œ (TimePicker ํ‘œ์‹œ)
case tapDietTimeDropDown
/// DatePicker / TimePicker ๋ฐ”ํ…€์‹œํŠธ์—์„œ ๋‚ ์งœ๋ฅผ ์„ ํƒํ–ˆ์„ ๋•Œ
case tapBottomSheetSubmitButton(FocusField, Date)
/// ์‹๋‹จ ํƒ€์ž… ๋ฒ„ํŠผ์ด ํƒญ๋˜์—ˆ์„ ๋•Œ
case tapDietTypeButton(DietType)
/// "์™„๋ฃŒ" ๋ฒ„ํŠผ์ด ๋ˆŒ๋ ธ์„ ๋•Œ
case tapSubmitButton
/// ํŒ์—… ์ขŒ์ธก secondary ๋ฒ„ํŠผ ํƒญ
case tapPopUpSecondaryButton(popUp: PopUp?)
/// ํŒ์—… ์šฐ์ธก primary ๋ฒ„ํŠผ ํƒญ
case tapPopUpPrimaryButton(popUp: PopUp?)
/// ํฌ์ปค์Šค ์ƒํƒœ ๋ณ€๊ฒฝ
case setFocus(FocusField?, FocusField?)
}
}

public init() {}

public var body: some ReducerOf<Self> {
BindingReducer(action: \.view)

Reduce { state, action in
switch action {
case .view(let action):
switch action {
case .binding(\.dietDate), .binding(\.dietTime), .binding(\.dietType):
return self.validateAllFields(&state)

case .binding(\.dietInfo):
state.view_dietInfoStatus = validateDietInfo(state.dietInfo)
return self.validateAllFields(&state)

case .binding(\.view_photoPickerItem):
let item: PhotosPickerItem? = state.view_photoPickerItem
return .run { [item] send in
if let item, let data = try? await item.loadTransferable(type: Data.self) {
await send(.imagePicked(data))
}
}

case .binding:
return .none

case .tapNavBackButton:
if state.view_isSubmitButtonEnabled {
return self.setPopUpStatus(&state, status: .cancelDietAdd)
} else {
return .run { send in
await self.dismiss()
}
}

case .tapPhotoPickerDeleteButton:
state.dietImageData = nil
state.view_photoPickerItem = nil
return .none

case .tapDietDateDropDown:
state.view_bottomSheetItem = .datePicker(.dietDate)
return .send(.view(.setFocus(state.view_focusField, .dietDate)))

case .tapDietTimeDropDown:
state.view_bottomSheetItem = .timePicker(.dietTime)
return .send(.view(.setFocus(state.view_focusField, .dietTime)))

case let .tapBottomSheetSubmitButton(field, date):
state.view_bottomSheetItem = nil

switch field {
case .dietDate:
state.dietDate = date
state.view_dietDateStatus = .filled
case .dietTime:
state.dietTime = date
state.view_dietTimeStatus = .filled
default:
return .none
}

return .concatenate(
.send(.view(.setFocus(field, nil))),
self.validateAllFields(&state)
)

case .tapDietTypeButton(let type):
state.dietType = type
return self.validateAllFields(&state)

case .tapSubmitButton:
return .send(.setNavigating)

case .tapPopUpSecondaryButton(let popUp):
guard popUp != nil else { return .none }
return setPopUpStatus(&state, status: nil)

case .tapPopUpPrimaryButton(let popUp):
guard popUp != nil else { return .none }
return setPopUpStatus(&state, status: nil)

case let .setFocus(oldFocus, newFocus):
guard oldFocus != newFocus else { return .none }
state.view_focusField = newFocus
return .none
}

case .imagePicked(let imgData):
state.dietImageData = imgData
return self.validateAllFields(&state)

case .setNavigating:
return .none
}
}
}
}

// MARK: Internal Logic
private extension TraineeAddDietRecordFeature {
/// ์‹๋‹จ ์ •๋ณด ์ƒํƒœ ๊ฒ€์ฆ
func validateDietInfo(_ info: String) -> TTextEditor.Status {
guard !info.isEmpty else { return .empty }
return info.count > 100 ? .invalid : .filled
}

/// ๋ชจ๋“  ํ•„๋“œ์˜ ์ƒํƒœ๋ฅผ ๊ฒ€์ฆํ•˜์—ฌ "๋‹ค์Œ" ๋ฒ„ํŠผ ํ™œ์„ฑํ™” ์—ฌ๋ถ€๋ฅผ ๊ฒฐ์ •
func validateAllFields(_ state: inout State) -> Effect<Action> {

guard state.dietImageData != nil else { return .none }
guard state.dietDate != nil else { return .none }
guard state.dietTime != nil else { return .none }
guard state.dietType != nil else { return .none }

state.view_isSubmitButtonEnabled = true
return .none
}

/// ํŒ์—… ์ƒํƒœ, ํ‘œ์‹œ ์ƒํƒœ๋ฅผ ์—…๋ฐ์ดํŠธ
/// status nil ์ž…๋ ฅ์ธ ๊ฒฝ์šฐ ํŒ์—… ํ‘œ์‹œ ํ•ด์ œ
func setPopUpStatus(_ state: inout State, status: PopUp?) -> Effect<Action> {
state.view_popUp = status
state.view_isPopUpPresented = status != nil
return .none
}
}

// MARK: BottomSheet
public extension TraineeAddDietRecordFeature {
enum BottomSheetItem: Equatable, Identifiable {
case datePicker(FocusField)
case timePicker(FocusField)

public var id: String {
switch self {
case .datePicker(let field):
return "datePicker" + field.title
case .timePicker(let field):
return "timePicker" + field.title
}
}

public var field: FocusField? {
switch self {
case .datePicker(let field):
return field
case .timePicker(let field):
return field
}
}
}
}

// MARK: PopUp
public extension TraineeAddDietRecordFeature {
/// ๋ณธ ํ™”๋ฉด์— ํŒ์—…์œผ๋กœ ํ‘œ์‹œ๋˜๋Š” ๋ชฉ๋ก
enum PopUp: Equatable, Sendable {
/// ์‹๋‹จ์„ ๊ธฐ๋กํ–ˆ์–ด์š”!
case dietAdded
/// ์‹๋‹จ ๊ธฐ๋ก์„ ์ข…๋ฃŒํ• ๊นŒ์š”?
case cancelDietAdd

var title: String {
switch self {
case .dietAdded:
return "์‹๋‹จ์„ ๊ธฐ๋กํ–ˆ์–ด์š”!"
case .cancelDietAdd:
return "์‹๋‹จ ๊ธฐ๋ก์„ ์ข…๋ฃŒํ• ๊นŒ์š”?"
}
}

var message: String {
switch self {
case .dietAdded:
return "๋‚ด์ผ๋„ ๊ธฐ๋กํ•ด ์ฃผ์‹ค ๊ฑฐ์ฃ ?"
case .cancelDietAdd:
return "๊ธฐ๋ก์ด ์ €์žฅ๋˜์ง€ ์•Š์•„์š”!"
}
}

var showAlertIcon: Bool {
switch self {
case .dietAdded:
return false
case .cancelDietAdd:
return true
}
}

var secondaryAction: Action.View? {
switch self {
case .dietAdded:
return nil
case .cancelDietAdd:
return .tapPopUpSecondaryButton(popUp: self)
return nil
}
}

var primaryTitle: String {
switch self {
case .dietAdded:
return "ํ™•์ธ"
case .cancelDietAdd:
return "๊ณ„์† ์ˆ˜์ •"
}
}

var primaryAction: Action.View {
return .tapPopUpPrimaryButton(popUp: self)
}
}
}
Loading

0 comments on commit bfb1af4

Please sign in to comment.