diff --git a/Solply/Solply/Presentation/JGD/Component/JGDSubTownRow.swift b/Solply/Solply/Global/Component/JGDSubTownRow.swift similarity index 100% rename from Solply/Solply/Presentation/JGD/Component/JGDSubTownRow.swift rename to Solply/Solply/Global/Component/JGDSubTownRow.swift diff --git a/Solply/Solply/Presentation/JGD/Component/JGDTopTownRow.swift b/Solply/Solply/Global/Component/JGDTopTownRow.swift similarity index 100% rename from Solply/Solply/Presentation/JGD/Component/JGDTopTownRow.swift rename to Solply/Solply/Global/Component/JGDTopTownRow.swift diff --git a/Solply/Solply/Presentation/AIRecommend/Component/AIRecommendBar.swift b/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Component/AIRecommendBar.swift similarity index 100% rename from Solply/Solply/Presentation/AIRecommend/Component/AIRecommendBar.swift rename to Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Component/AIRecommendBar.swift diff --git a/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Component/TownSelectBottomSheet.swift b/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Component/TownSelectBottomSheet.swift new file mode 100644 index 00000000..66641ea3 --- /dev/null +++ b/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Component/TownSelectBottomSheet.swift @@ -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 + ) { + self.isTownLoading = isTownLoading + self.townList = townList + self.initialTown = initialTown + self.initialSubTown = initialSubTown + self.onAppear = onAppear + self.onComplete = onComplete + } + + // 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) + } + .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) + } + .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) + } +} diff --git a/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Effect/AIRecommendEffect.swift b/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Effect/AIRecommendEffect.swift new file mode 100644 index 00000000..01fef6cc --- /dev/null +++ b/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Effect/AIRecommendEffect.swift @@ -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) + } + } +} diff --git a/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Intent/AIRecommendPromptAction.swift b/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Intent/AIRecommendPromptAction.swift index 82ee44a4..2e5d9e41 100644 --- a/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Intent/AIRecommendPromptAction.swift +++ b/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Intent/AIRecommendPromptAction.swift @@ -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) } diff --git a/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Intent/AIRecommendPromptStore.swift b/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Intent/AIRecommendPromptStore.swift index 88928101..b68b563a 100644 --- a/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Intent/AIRecommendPromptStore.swift +++ b/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/Intent/AIRecommendPromptStore.swift @@ -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 } diff --git a/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/State/AIRecommendPromptReducer.swift b/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/State/AIRecommendPromptReducer.swift index a46a172a..7dc485cf 100644 --- a/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/State/AIRecommendPromptReducer.swift +++ b/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/State/AIRecommendPromptReducer.swift @@ -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) } } } diff --git a/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/State/AIRecommendPromptState.swift b/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/State/AIRecommendPromptState.swift index e43efc32..735425dd 100644 --- a/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/State/AIRecommendPromptState.swift +++ b/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/State/AIRecommendPromptState.swift @@ -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 + + } diff --git a/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/View/AIRecommendPromptView.swift b/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/View/AIRecommendPromptView.swift index ec44b277..7832921e 100644 --- a/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/View/AIRecommendPromptView.swift +++ b/Solply/Solply/Presentation/AIRecommend/AIRecommendPrompt/View/AIRecommendPromptView.swift @@ -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) + } } } @@ -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)