Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//
// TownSelectBottomSheet.swift
// Solply
//
// Created by seozero on 3/19/26.
//

import SwiftUI

struct TownSelectBottomSheet: View {

// MARK: - Properties

@Environment(\.dismiss) private var dismiss

@State private var selectedTown: Town?
@State private var selectedSubTown: SubTown?

private let isTownLoading: Bool
private let townList: [Town]

private let initialTown: Town?
private let initialSubTown: SubTown?

private let onAppear: (() -> Void)?
private let onComplete: ((Town?, SubTown?) -> Void)?

init(
isTownLoading: Bool,
townList: [Town],
initialTown: Town? = nil,
initialSubTown: SubTown? = nil,
onAppear: (() -> Void)? = nil,
onComplete: ((Town?, SubTown?) -> Void)? = nil
) {
Comment on lines +33 to +35
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그리고 onComplete 클로저에 Town이랑 SubTownselectedTown, selectedSubTown 때문에 옵셔널인 거 같은데, 얘도 옵셔널이 아니어도 괜찮을 거 같아요! 혹시 제가 놓친 부분이 있다면 알려주세용

self.isTownLoading = isTownLoading
self.townList = townList
self.initialTown = initialTown
self.initialSubTown = initialSubTown
self.onAppear = onAppear
self.onComplete = onComplete
}
Comment on lines +16 to +42
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시 selectedTown이랑 selectedSubTown을 옵셔널로 두고, initialTown, initialSubTown을 두신 건 초기값 때문인 건가요? selectedTown이랑 selectedSubTown은 선택이 안 되어있는 상황은 없으니까 옵셔널로 두지 않아도 될 거 같습니다! 그리고

init(
    isTownLoading: Bool,
    townList: [Town],
    initialTown: Town,
    initialSubTown: SubTown,
    onAppear: (() -> Void? = nil,
    onComplete: ((Town, SubTown) -> Void)? = nil
) {
    self.isTownLoading = isTownLoading
    self.townList = townList
    self.onAppear = onAppear
    self.onComplete = onComplete
    self._selectedTown = State(initialValue: initialTown)
    self._selectedSubTown = State(initialValue: initialSubTown)
}

이렇게 초기값을 설정해주는 것은 어떤가요?


Comment on lines +13 to +43
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 뷰는 AIRecommendPromptView의 서브뷰 역할을 하기 때문에, Store를 직접 들고 있는 건 적절하지 않아 보입니다!

@StateObject로 선언하면 이 뷰가 Store의 생명주기를 직접 수요하게 되는데,, 서브뷰가 Store를 소유하는 건 의도한 구조가 아닐 거라고 생각이 드네욥, @ObservedObject로 바꾼다고 하더라도, 서브뷰가 Store자체를 참조하게 되면 MVI 패턴에서 의도하는 단방향 데이터 흐름이 깨진다고 생각합니다!

Store를 여러 뷰에서 직접 참조하게 되면 어느 뷰에서 상태 변경이 발생했는지 추적하기 어려워지고, 뷰 간 결합도도 높아지기 때문에 유지보수가 어려워질 수 있을 거 같아요

MVI 패턴에 더 맞는 방향은, Store를 넘겨주는 것보단, 실행할 클로저를 프로퍼티로 선언하고, 부모뷰(AIRecommendPromptView)에서 초기화할 때 해당 클로저를 전달해주는 방식을 사용하는 게 더 적절한 거 같습니다. 요렇게 하면 서브뷰는 의도만 전달하는 역할에 집중하게 되고, 실제 상태를 변경하는 거는 부모 뷰에서만 일어나도록 책임을 분리할 수 있습니다!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예를 들어 지금 이 컴포에서 저 동네 리스트를 띄우기 위해 필요한 배열이라던가, '완료' 버튼을 누르면 실행할 action이라던가 등등.. 을 프로퍼티로 정의한 뒤에 부모뷰에서 초기화할 때 넘겨주는 방식으로 하면 결합도를 낮출 수 있을 거 같아요

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MVI 쓰면서 멀어진 것들 : @State, @StateObject.......

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@StateObject는 ObservableObject 객체를 최초 생성하고 생명주기를 소유하게 되는 거라서, View에서 @StateObject로 Store를 초기화 해서 최초 1회 생성을 보장하는 겁니다. 이미 우리 Store 초기화할 때 @StateObject로 해왔는뎁.. @dudwntjs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image 웁씨 세상에

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서브 뷰가 Store를 직접 가졌을 때, 우리가 의도한 단방향 데이터 흐름이 깨진다는 말이 이해가 갔습니다 !
수정해보았는데, 이런 식으로 하는 게 맞을까 싶어요. 코드 다시 한 번 봐주시면 감사하겠습니다 ~~

// MARK: - Body

var body: some View {
ZStack(alignment: .bottom) {
VStack(alignment: .center, spacing: 0) {
header

if !isTownLoading {
divider
}

HStack(alignment: .top, spacing: 0) {
townListView

Divider()

subTownListView
}
.customLoading(.JGDLoading, isLoading: isTownLoading)
}
.ignoresSafeArea(edges: .bottom)

SolplyMainButton(
title: "완료",
isEnabled: true
) {
onComplete?(selectedTown, selectedSubTown)
}
Comment on lines +66 to +71
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

별 건 아닌데, 어떤 작업 연결해야하는지 TODO 주석으로 남겨주세용

.padding(.horizontal, 20.adjustedWidth)
.padding(.bottom, 38.adjustedHeight)
}
.onAppear {
onAppear?()
}
.onChange(of: townList) {
if selectedTown == nil {
selectedTown = initialTown
selectedSubTown = initialSubTown
}
}
}
}

// MARK: - Subviews

extension TownSelectBottomSheet {
private var header: some View {
HStack(alignment: .center, spacing: 0) {
Text("동네")
.applySolplyFont(.title_18_sb)
.foregroundStyle(.coreBlack)

Spacer()

Button {
dismiss()
} label: {
Image(.xIconSm)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 24.adjusted, height: 24.adjusted)
.foregroundStyle(.gray800)
Comment on lines +101 to +105
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image(아이콘) 리사이저블 밑에 .aspectRatio(contentMode: .fit) 달아주세요!

}
.buttonStyle(.plain)
}
.padding(.horizontal, 16.adjustedWidth)
.padding(.top, 24.adjustedHeight)
.padding(.bottom, 20.adjustedHeight)
}

private var divider: some View {
Rectangle()
.frame(height: 1)
.foregroundStyle(.gray300)
}

private var townListView: some View {
VStack(alignment: .center, spacing: 0) {
ForEach(townList, id: \.self) { town in
JGDTopTownRow(
title: town.townName,
isSelected: selectedTown?.id == town.id
) {
selectedTown = town
selectedSubTown = nil
}
}

Spacer()
}
.frame(maxHeight: .infinity)
.background(.gray100)
}

private var subTownListView: some View {
let subTowns = selectedTown?.subTowns ?? []

return VStack(alignment: .center, spacing: 0) {
ForEach(subTowns, id: \.self) { subTown in
JGDSubTownRow(
title: subTown.townName,
isSelected: selectedSubTown?.id == subTown.id
) {
selectedSubTown = subTown
}
}

Spacer()
}
.frame(width: 247.adjustedWidth)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// AIRecommendEffect.swift
// Solply
//
// Created by seozero on 3/20/26.
//

import Foundation

struct AIRecommendEffect {
private let townService: TownAPI

init(townService: TownAPI) {
self.townService = townService
}

func fetchTowns() async -> AIRecommendPromptAction {
do {
let response = try await townService.fetchTownList()

guard let data = response.data else {
return .fetchTownsFailure(error: .responseError)
}

let towns = data.toEntity()

return .fetchTownsSuccess(townList: towns)
} catch let error as NetworkError {
return .fetchTownsFailure(error: error)
} catch {
return .fetchTownsFailure(error: .unknownError)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,20 @@ enum AIRecommendPromptAction {
case selectTab(selectedCategory: SolplyContentType)
case toggleWritingGuide
case updatePromptText(String)
case showTownSelectBottomSheet(isSheetPresented: Bool)

// MARK: - TownSelectBottomSheet

case fetchTowns
case fetchTownsSuccess(townList: [Town])
case fetchTownsFailure(error: NetworkError)

case setInitialTownId(townId: Int)

case selectTown(Town)
case selectSubTown(SubTown)

case saveSelection
case saveSelectionSuccess
case saveSelectionFailure(error: NetworkError)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,20 @@ import Foundation
final class AIRecommendPromptStore: ObservableObject {
@Published private(set) var state = AIRecommendPromptState()

private let effect = AIRecommendEffect(
townService: TownService()
)

func dispatch(_ action: AIRecommendPromptAction) {
AIRecommendPromptReducer.reduce(state: &state, action: action)

switch action {
case .fetchTowns:
Task {
let result = await effect.fetchTowns()
dispatch(result)
}

default:
break
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,67 @@ enum AIRecommendPromptReducer {

case .updatePromptText(let text):
state.isRecommendButtonEnabled = text.count >= 5

case .showTownSelectBottomSheet(let isSheetPresented):
state.isTownSelectBottomSheetPresented = isSheetPresented

// api

case .fetchTowns:
state.isTownLoading = true

case .fetchTownsSuccess(let townList):
state.townList = townList

let subTowns = townList.flatMap { $0.subTowns }
state.selectedSubTown = subTowns.first { $0.id == state.initialTownId }
state.currentSelectedSubTown = state.selectedSubTown

if let selectedSubTown = state.selectedSubTown {
state.selectedTown = townList.first { $0.subTowns.contains(selectedSubTown) }
} else {
if let firstTown = townList.first {
state.selectedTown = firstTown
state.selectedSubTown = firstTown.subTowns.first
state.currentSelectedSubTown = state.selectedSubTown
} else {
state.selectedTown = nil
state.selectedSubTown = nil
state.currentSelectedSubTown = nil
}
}

state.isTownLoading = false

case .fetchTownsFailure(let error):
print(error)

case .setInitialTownId(let townId):
state.initialTownId = townId

case .selectTown(let town):
state.selectedTown = town

let subTowns = town.subTowns

if let current = state.currentSelectedSubTown, subTowns.contains(current) {
state.selectedSubTown = current
} else {
state.selectedSubTown = subTowns.first
}

case .selectSubTown(let subTown):
state.selectedSubTown = subTown
state.currentSelectedSubTown = subTown


// TODO: - API에 따라 달라질듯
case .saveSelection:
break
case .saveSelectionSuccess:
break
case .saveSelectionFailure(let error):
print(error)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,19 @@ struct AIRecommendPromptState {
var selectedCategory: SolplyContentType = .place
var isWritingGuidePresented: Bool = false
var isRecommendButtonEnabled: Bool = false
var isTownSelectBottomSheetPresented: Bool = false

// MARK: - TownSelectBottomSheet

var isTownLoading: Bool = false
var isCompleteButtonLoading: Bool = false

var initialTownId: Int = 0
var currentSelectedSubTown: SubTown? = nil

var townList: [Town] = []
var selectedTown: Town? = nil
var selectedSubTown: SubTown? = nil


}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,25 @@ struct AIRecommendPromptView: View {
appCoordinator.goBack()
})
.customModal()
.sheet(
isPresented: Binding(
get: { store.state.isTownSelectBottomSheetPresented },
set: { store.dispatch(.showTownSelectBottomSheet(isSheetPresented: $0)) }
)
) {
TownSelectBottomSheet(
isTownLoading: store.state.isTownLoading,
townList: store.state.townList,
initialTown: store.state.selectedTown,
initialSubTown: store.state.selectedSubTown,
onAppear: { store.dispatch(.fetchTowns) },
onComplete: { town, subTown in
// TODO: 완료 버튼 기능 구현 후 연결
}
)
.presentationDetents([.height(654.adjustedHeight)])
.presentationCornerRadius(20)
}
}
}

Expand Down Expand Up @@ -62,7 +81,7 @@ extension AIRecommendPromptView {

private var townSelect: some View {
Button {
// TODO: - 동네 선택 바텀시트 연결
store.dispatch(.showTownSelectBottomSheet(isSheetPresented: true))
} label: {
HStack(alignment: .center, spacing: 4.adjustedWidth) {
Image(.townIcon)
Expand Down