feat(home): 팔로잉 탭 API 상태를 추가한다

This commit is contained in:
Yu Sung
2026-06-30 22:04:40 +09:00
parent e2d46614bd
commit ae09776c9b
9 changed files with 423 additions and 10 deletions

View File

@@ -0,0 +1,73 @@
import Foundation
import Combine
final class MainHomeFollowingViewModel: ObservableObject {
private let repository = MainHomeFollowingRepository()
private var subscription = Set<AnyCancellable>()
@Published var isLoading = false
@Published var response: HomeFollowingTabResponse?
@Published var isLoginRequired = false
@Published var message = ""
@Published var hasLoaded = false
func fetchFollowing() {
isLoading = true
message = ""
repository.getFollowing()
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.response = nil
self.isLoginRequired = false
self.message = I18n.HomeFollowing.loadFailedMessage
self.hasLoaded = true
self.isLoading = false
}
} receiveValue: { [weak self] response in
guard let self else { return }
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<HomeFollowingTabResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.isLoginRequired = data.isLoginRequired
self.response = data.isLoginRequired ? nil : data
self.message = data.isLoginRequired || data.hasContent ? "" : I18n.HomeFollowing.emptyStateMessage
} else {
self.response = nil
self.isLoginRequired = false
self.message = I18n.HomeFollowing.loadFailedMessage
}
} catch {
ERROR_LOG(error.localizedDescription)
self.response = nil
self.isLoginRequired = false
self.message = I18n.HomeFollowing.loadFailedMessage
}
self.hasLoaded = true
self.isLoading = false
}
.store(in: &subscription)
}
}
private extension HomeFollowingTabResponse {
var hasContent: Bool {
!followingCreators.isEmpty ||
!onAirLives.isEmpty ||
!recentChats.isEmpty ||
!monthlySchedules.isEmpty ||
!recentNews.isEmpty
}
}

View File

@@ -0,0 +1,154 @@
import Foundation
struct HomeFollowingTabResponse: Decodable {
let isLoginRequired: Bool
let followingCreators: [FollowingCreatorResponse]
let onAirLives: [FollowingLiveResponse]
let recentChats: [ChatRoomListItemResponse]
let monthlySchedules: [FollowingScheduleResponse]
let recentNews: [FollowingNewsResponse]
}
struct FollowingCreatorResponse: Decodable, Identifiable {
let creatorId: Int
let creatorNickname: String
let creatorProfileImageUrl: String
var id: Int { creatorId }
}
struct FollowingLiveResponse: Decodable, Identifiable {
let liveId: Int
let creatorProfileImageUrl: String
let creatorNickname: String
let title: String
let startedAtUtc: String
var id: Int { liveId }
}
struct ChatRoomListItemResponse: Decodable, Identifiable {
let roomId: Int
let chatType: String
let targetName: String
let targetImageUrl: String
let lastMessage: String
let lastMessageAt: String
var id: Int { roomId }
}
struct FollowingScheduleResponse: Decodable, Identifiable {
let scheduleId: String
let creatorId: Int
let creatorProfileImageUrl: String
let creatorNickname: String
let title: String
let type: CreatorActivityType
let targetId: Int
let scheduledAtUtc: String
let isOnAir: Bool
var id: String { scheduleId }
}
enum CreatorActivityType: Decodable, Hashable {
case live
case audio
case community
case liveReplay
case unknown(String)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let code = try container.decode(String.self)
switch code {
case "LIVE":
self = .live
case "AUDIO":
self = .audio
case "COMMUNITY":
self = .community
case "LIVE_REPLAY":
self = .liveReplay
default:
self = .unknown(code)
}
}
}
struct FollowingNewsResponse: Decodable, Identifiable {
let newsId: String
let type: FollowingNewsType
let visibleFromAtUtc: String
let creatorRanking: FollowingCreatorRankingNewsResponse?
let audioContent: FollowingContentNewsResponse?
let photoContent: FollowingContentNewsResponse?
let contentRanking: FollowingContentRankingNewsResponse?
let communityPost: FollowingCommunityPostNewsResponse?
var id: String { newsId }
}
struct FollowingCreatorRankingNewsResponse: Decodable {
let rank: Int
let creatorId: Int
let nickname: String
let profileImageUrl: String
}
struct FollowingContentNewsResponse: Decodable {
let contentId: Int
let contentImageUrl: String?
let title: String
let creatorProfileImageUrl: String
let creatorNickname: String
}
struct FollowingContentRankingNewsResponse: Decodable {
let rank: Int
let contentId: Int
let contentImageUrl: String?
let title: String
}
struct FollowingCommunityPostNewsResponse: Decodable {
let postId: Int
let creatorProfileImage: String
let creatorNickname: String
let imageUrl: String?
let content: String
let createdAt: String
let likeCount: Int
let commentCount: Int
}
enum FollowingNewsType: Decodable, Hashable {
case creatorRanking
case contentRanking
case communityPost
case audioContent
case photoContent
case unknown(String)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let code = try container.decode(String.self)
switch code {
case "CREATOR_RANKING":
self = .creatorRanking
case "CONTENT_RANKING":
self = .contentRanking
case "COMMUNITY_POST":
self = .communityPost
case "AUDIO_CONTENT":
self = .audioContent
case "PHOTO_CONTENT":
self = .photoContent
default:
self = .unknown(code)
}
}
}

View File

@@ -0,0 +1,38 @@
import Foundation
import Moya
enum MainHomeFollowingApi {
case getFollowing
}
extension MainHomeFollowingApi: TargetType {
var baseURL: URL {
return URL(string: BASE_URL)!
}
var path: String {
switch self {
case .getFollowing:
return "/api/v2/home/following"
}
}
var method: Moya.Method {
switch self {
case .getFollowing:
return .get
}
}
var task: Moya.Task {
switch self {
case .getFollowing:
return .requestPlain
}
}
var headers: [String: String]? {
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
}
}

View File

@@ -0,0 +1,12 @@
import Foundation
import CombineMoya
import Combine
import Moya
final class MainHomeFollowingRepository {
private let api = MoyaProvider<MainHomeFollowingApi>()
func getFollowing() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getFollowing)
}
}

View File

@@ -11,6 +11,7 @@ struct MainHomeView: View {
let onTapCommunity: (Int) -> Void
let onTapBanner: (RecommendationBannerResponse) -> Void
let onTapFollowAll: (@escaping () -> Void) -> Void
let onSelectFollowingTab: () -> Bool
@State private var selectedTab: MainHomeTab = .recommendation
@@ -31,7 +32,7 @@ struct MainHomeView: View {
TextTabBar(
items: MainHomeTab.allCases,
selectedItem: $selectedTab,
selectedItem: selectedTabBinding,
title: { $0.title }
)
@@ -41,6 +42,19 @@ struct MainHomeView: View {
.background(Color.black.ignoresSafeArea())
}
private var selectedTabBinding: Binding<MainHomeTab> {
Binding(
get: { selectedTab },
set: { newValue in
if newValue == .following, onSelectFollowingTab() == false {
return
}
selectedTab = newValue
}
)
}
@ViewBuilder
private var tabContent: some View {
switch selectedTab {
@@ -91,7 +105,8 @@ struct MainHomeView_Previews: PreviewProvider {
onTapCharacter: { _ in },
onTapCommunity: { _ in },
onTapBanner: { _ in },
onTapFollowAll: { action in action() }
onTapFollowAll: { action in action() },
onSelectFollowingTab: { true }
)
}
}

View File

@@ -163,7 +163,8 @@ struct MainView: View {
onTapCharacter: handleRecommendationCharacterTap,
onTapCommunity: handleRecommendationCommunityTap,
onTapBanner: handleRecommendationBannerTap,
onTapFollowAll: handleRecommendationFollowAllTap
onTapFollowAll: handleRecommendationFollowAllTap,
onSelectFollowingTab: handleFollowingTabSelection
)
case .content:
MainPlaceholderTabView(title: MainTab.content.title)
@@ -484,6 +485,15 @@ struct MainView: View {
performRecommendationDetailAction(action: action)
}
private func handleFollowingTabSelection() -> Bool {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
AppState.shared.setAppStep(step: .login)
return false
}
return true
}
private func handleRecommendationBannerTap(_ item: RecommendationBannerResponse) {
if let eventItem = item.eventItem {