feat(event): ID 기반 이벤트 상세 API를 연동한다
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user