feat(event): ID 기반 이벤트 상세 API를 연동한다

This commit is contained in:
Yu Sung
2026-10-29 12:03:02 +09:00
parent d664448f38
commit 468a766242
15 changed files with 549 additions and 44 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ enum AppStep {
case events
case eventDetail(event: EventItem)
case eventDetail(eventId: Int)
case terms
@@ -103,7 +103,7 @@ private struct ContentMainBannerPage: View {
.onTapGesture {
switch item.type {
case .EVENT:
AppState.shared.setAppStep(step: .eventDetail(event: item.eventItem!))
AppState.shared.setAppStep(step: .eventDetail(eventId: item.eventItem!.id))
case .CREATOR:
AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId!))
case .SERIES:
@@ -126,7 +126,7 @@ struct ContentMainBannerImageView: View {
.onTapGesture {
switch item.type {
case .EVENT:
AppState.shared.setAppStep(step: .eventDetail(event: item.eventItem!))
AppState.shared.setAppStep(step: .eventDetail(eventId: item.eventItem!.id))
case .CREATOR:
AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId!))
case .SERIES:
+2 -2
View File
@@ -146,8 +146,8 @@ struct AppStepLayerView: View {
case .events:
EventListView()
case .eventDetail(let event):
EventDetailView(event: event)
case .eventDetail(let eventId):
EventDetailView(eventId: eventId)
case .terms:
TermsView(isPrivacyPolicy: false)
@@ -96,7 +96,7 @@ private struct SectionEventBannerPage: View {
.onTapGesture {
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let _ = item.detailImageUrl {
AppState.shared.setAppStep(step: .eventDetail(event: item))
AppState.shared.setAppStep(step: .eventDetail(eventId: item.id))
} else if let link = item.link,
link.trimmingCharacters(in: .whitespaces).count > 0,
let url = URL(string: link),
@@ -31,7 +31,7 @@ struct EventPopupDialogView: View {
.shared
.setAppStep(
step: .eventDetail(
event: eventPopup
eventId: eventPopup.id
)
)
AppState.shared.eventPopup = nil
@@ -11,6 +11,7 @@ import Moya
enum EventApi {
case getEvents
case getEventPopup
case getEventDetail(id: Int)
}
extension EventApi: TargetType {
@@ -25,24 +26,30 @@ extension EventApi: TargetType {
case .getEventPopup:
return "/event/popup"
case .getEventDetail(let id):
return "/api/v2/events/\(id)"
}
}
var method: Moya.Method {
switch self {
case .getEvents, .getEventPopup:
case .getEvents, .getEventPopup, .getEventDetail:
return .get
}
}
var task: Task {
switch self {
case .getEvents, .getEventPopup:
case .getEvents, .getEventPopup, .getEventDetail:
return .requestPlain
}
}
var headers: [String : String]? {
if case .getEventDetail = self, UserDefaults.string(forKey: .token).isEmpty {
return nil
}
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
}
}
@@ -9,38 +9,50 @@ import SwiftUI
import Kingfisher
struct EventDetailView: View {
let event: EventItem
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: EventDetailViewModel
init(eventId: Int) {
_viewModel = StateObject(wrappedValue: EventDetailViewModel(eventId: eventId))
}
var body: some View {
BaseView {
BaseView(isLoading: $viewModel.isLoading) {
GeometryReader { proxy in
VStack(spacing: 0) {
DetailNavigationBar(title: I18n.Settings.Event.detailTitle)
DetailNavigationBar(title: I18n.Settings.Event.detailTitle, backAction: {
viewModel.cancel()
dismiss()
})
ScrollView(.vertical, showsIndicators: false) {
KFImage(URL(string: event.detailImageUrl!))
.cancelOnDisappear(true)
.resizable()
.scaledToFit()
if case .loaded(let event) = viewModel.state,
let detailImageUrl = event.detailImageUrl {
KFImage(URL(string: detailImageUrl))
.cancelOnDisappear(true)
.resizable()
.scaledToFit()
}
}
Spacer()
if let link = event.link, link.count > 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) {
Text(I18n.Settings.Event.participateButton)
.appFont(size: 18.3, weight: .bold)
.foregroundColor(.white)
.padding(.vertical, 16)
.frame(width: screenSize().width - 26.7)
.background(Color(hex: "3e737c"))
.cornerRadius(10)
.padding(13.3)
.background(Color(hex: "222222"))
.cornerRadius(16.7, corners: [.topLeft, .topRight])
.onTapGesture {
UIApplication.shared.open(url)
}
if let url = viewModel.participationURL, UIApplication.shared.canOpenURL(url) {
Button {
UIApplication.shared.open(url)
} label: {
Text(I18n.Settings.Event.participateButton)
.appFont(size: 18.3, weight: .bold)
.foregroundColor(.white)
.padding(.vertical, 16)
.frame(width: screenSize().width - 26.7)
.background(Color(hex: "3e737c"))
.cornerRadius(10)
.padding(13.3)
.background(Color(hex: "222222"))
.cornerRadius(16.7, corners: [.topLeft, .topRight])
}
.buttonStyle(.plain)
}
if proxy.safeAreaInsets.bottom > 0 {
@@ -52,19 +64,23 @@ struct EventDetailView: View {
.edgesIgnoringSafeArea(.bottom)
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear {
viewModel.load()
}
.onDisappear {
viewModel.cancel()
}
.onReceive(viewModel.$state) { state in
if state == .unavailable, viewModel.takeUnavailableDismissal() {
dismiss()
}
}
}
}
struct EventDetailView_Previews: PreviewProvider {
static var previews: some View {
EventDetailView(
event: EventItem(
id: 1,
thumbnailImageUrl: "",
detailImageUrl: "",
popupImageUrl: "",
link: "http://m.naver.com"
)
)
EventDetailView(eventId: 0)
}
}
@@ -38,7 +38,7 @@ struct EventListView: View {
.contentShape(Rectangle())
.onTapGesture {
if let _ = event.detailImageUrl {
AppState.shared.setAppStep(step: .eventDetail(event: event))
AppState.shared.setAppStep(step: .eventDetail(eventId: event.id))
} else if let link = event.link, link.trimmingCharacters(in: .whitespaces).count > 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
}
@@ -20,5 +20,8 @@ final class EventRepository {
func getEventPopup() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getEventPopup)
}
}
func getEventDetail(id: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getEventDetail(id: id))
}
}
+2 -2
View File
@@ -637,7 +637,7 @@ struct MainView: View {
private func handleRecommendationBannerTap(_ item: RecommendationBannerResponse) {
if let eventItem = item.eventItem {
performRecommendationDetailAction {
appState.setAppStep(step: .eventDetail(event: eventItem))
appState.setAppStep(step: .eventDetail(eventId: eventItem.id))
}
return
}
@@ -660,7 +660,7 @@ struct MainView: View {
private func handleContentAudioBannerTap(_ item: AudioBannerResponse) {
if let eventItem = item.eventItem {
performRecommendationDetailAction {
appState.setAppStep(step: .eventDetail(event: eventItem))
appState.setAppStep(step: .eventDetail(eventId: eventItem.id))
}
return
}
@@ -0,0 +1,114 @@
import Foundation
import Combine
final class EventDetailViewModel: ObservableObject {
enum State: Equatable {
case idle
case loading
case loaded(EventItem)
case unavailable
case error
}
@Published private(set) var state: State = .idle
@Published var isLoading = false
@Published var errorMessage = ""
@Published var isShowPopup = false
private let eventId: Int
private let repository = EventRepository()
private var subscription: AnyCancellable?
private var activeRequestID: UUID?
private var isUnavailableDismissalPending = false
init(eventId: Int) {
self.eventId = eventId
}
var participationURL: URL? {
guard case .loaded(let event) = state,
let link = event.link,
let url = URL(string: link),
let scheme = url.scheme?.lowercased(),
["http", "https"].contains(scheme),
let host = url.host, !host.isEmpty else { return nil }
return url
}
func load() {
guard state == .idle else { return }
guard eventId > 0 else {
showUnavailable()
return
}
isLoading = true
state = .loading
let requestID = UUID()
activeRequestID = requestID
subscription = repository.getEventDetail(id: eventId)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self, self.activeRequestID == requestID else { return }
self.activeRequestID = nil
self.showError()
} receiveValue: { [weak self] response in
guard let self, self.activeRequestID == requestID else { return }
self.activeRequestID = nil
self.isLoading = false
guard response.statusCode == 200 else {
self.showError()
return
}
do {
let decoder = JSONDecoder()
let envelope = try decoder.decode(ApiResponseWithoutData.self, from: response.data)
guard envelope.success else {
self.showUnavailable()
return
}
let decoded = try decoder.decode(ApiResponse<EventItem>.self, from: response.data)
guard let event = decoded.data, event.id > 0, event.id == self.eventId else {
self.showError()
return
}
self.state = .loaded(event)
} catch {
self.showError()
}
}
}
func cancel() {
activeRequestID = nil
isUnavailableDismissalPending = false
subscription?.cancel()
subscription = nil
isLoading = false
if state == .loading {
state = .idle
}
}
func takeUnavailableDismissal() -> Bool {
guard isUnavailableDismissalPending else { return false }
isUnavailableDismissalPending = false
return true
}
private func showUnavailable() {
isLoading = false
isUnavailableDismissalPending = true
state = .unavailable
}
private func showError() {
isLoading = false
errorMessage = I18n.Common.commonError
isShowPopup = true
state = .error
}
}