diff --git a/SodaLive/Resources/Assets.xcassets/ic_sns_fancimm.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_sns_fancimm.imageset/Contents.json new file mode 100644 index 0000000..ac595be --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_sns_fancimm.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_sns_fancimm.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_sns_fancimm.imageset/ic_sns_fancimm.png b/SodaLive/Resources/Assets.xcassets/ic_sns_fancimm.imageset/ic_sns_fancimm.png new file mode 100644 index 0000000..fac6351 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_sns_fancimm.imageset/ic_sns_fancimm.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_sns_instagram.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_sns_instagram.imageset/Contents.json new file mode 100644 index 0000000..705304d --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_sns_instagram.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_sns_instagram.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_sns_instagram.imageset/ic_sns_instagram.png b/SodaLive/Resources/Assets.xcassets/ic_sns_instagram.imageset/ic_sns_instagram.png new file mode 100644 index 0000000..9c3cf62 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_sns_instagram.imageset/ic_sns_instagram.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_sns_kakao.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_sns_kakao.imageset/Contents.json new file mode 100644 index 0000000..eef0239 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_sns_kakao.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_sns_kakao.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_sns_kakao.imageset/ic_sns_kakao.png b/SodaLive/Resources/Assets.xcassets/ic_sns_kakao.imageset/ic_sns_kakao.png new file mode 100644 index 0000000..b6d2059 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_sns_kakao.imageset/ic_sns_kakao.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_sns_x.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_sns_x.imageset/Contents.json new file mode 100644 index 0000000..a5aa288 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_sns_x.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_sns_x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_sns_x.imageset/ic_sns_x.png b/SodaLive/Resources/Assets.xcassets/ic_sns_x.imageset/ic_sns_x.png new file mode 100644 index 0000000..ca1a2c7 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_sns_x.imageset/ic_sns_x.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_sns_youtube.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_sns_youtube.imageset/Contents.json new file mode 100644 index 0000000..096df8a --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_sns_youtube.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_sns_youtube.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_sns_youtube.imageset/ic_sns_youtube.png b/SodaLive/Resources/Assets.xcassets/ic_sns_youtube.imageset/ic_sns_youtube.png new file mode 100644 index 0000000..885fbbe Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_sns_youtube.imageset/ic_sns_youtube.png differ diff --git a/SodaLive/Sources/Explorer/ExplorerApi.swift b/SodaLive/Sources/Explorer/ExplorerApi.swift index 041ec05..e9c9f76 100644 --- a/SodaLive/Sources/Explorer/ExplorerApi.swift +++ b/SodaLive/Sources/Explorer/ExplorerApi.swift @@ -13,6 +13,7 @@ enum ExplorerApi { case getExplorer case searchChannel(channel: String) case getCreatorProfile(userId: Int, isAdultContentVisible: Bool) + case getCreatorDetail(userId: Int) case getFollowerList(userId: Int, page: Int, size: Int) case getCreatorProfileCheers(userId: Int, page: Int, size: Int) case writeCheers(parentCheersId: Int?, creatorId: Int, content: String) @@ -39,6 +40,9 @@ extension ExplorerApi: TargetType { case .getCreatorProfile(let userId, _): return "/explorer/profile/\(userId)" + + case .getCreatorDetail(let userId): + return "/explorer/profile/\(userId)/detail" case .getCreatorProfileDonationRanking(let userId, _, _, _): return "/explorer/profile/\(userId)/donation-rank" @@ -62,7 +66,7 @@ extension ExplorerApi: TargetType { var method: Moya.Method { switch self { - case .getExplorer, .searchChannel, .getCreatorProfile, .getFollowerList, .getCreatorProfileCheers, .getCreatorProfileDonationRanking, .getCreatorRank: + case .getExplorer, .searchChannel, .getCreatorProfile, .getCreatorDetail, .getFollowerList, .getCreatorProfileCheers, .getCreatorProfileDonationRanking, .getCreatorRank: return .get case .writeCheers, .writeCreatorNotice: @@ -75,7 +79,7 @@ extension ExplorerApi: TargetType { var task: Task { switch self { - case .getExplorer, .getCreatorRank: + case .getExplorer, .getCreatorRank, .getCreatorDetail: return .requestPlain case .searchChannel(let channel): diff --git a/SodaLive/Sources/Explorer/ExplorerRepository.swift b/SodaLive/Sources/Explorer/ExplorerRepository.swift index 37f910a..cc82bc6 100644 --- a/SodaLive/Sources/Explorer/ExplorerRepository.swift +++ b/SodaLive/Sources/Explorer/ExplorerRepository.swift @@ -29,6 +29,10 @@ final class ExplorerRepository { ) ) } + + func getCreatorDetail(id: Int) -> AnyPublisher { + return api.requestPublisher(.getCreatorDetail(userId: id)) + } func getFollowerList(userId: Int, page: Int, size: Int) -> AnyPublisher { return api.requestPublisher(.getFollowerList(userId: userId, page: page, size: size)) diff --git a/SodaLive/Sources/Explorer/Profile/Detail/CreatorDetailDialogView.swift b/SodaLive/Sources/Explorer/Profile/Detail/CreatorDetailDialogView.swift new file mode 100644 index 0000000..d7202d4 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/Detail/CreatorDetailDialogView.swift @@ -0,0 +1,217 @@ +// +// CreatorDetailDialogView.swift +// SodaLive +// +// Created by klaus on 2/25/26. +// + +import SwiftUI +import UIKit + +import Kingfisher + +struct CreatorDetailDialogView: View { + + @Binding var isShowing: Bool + let creatorDetail: GetCreatorDetailResponse? + let isLoading: Bool + + @Environment(\.openURL) private var openURL + + private var closeIconAssetName: String { + UIImage(named: "ic_x_white") != nil ? "ic_x_white" : "ic_close_white" + } + + private var profileImageSize: CGFloat { + screenSize().width - 26.7 - 48 + } + + private var snsItems: [CreatorDetailSnsItem] { + guard let creatorDetail else { return [] } + + var items = [CreatorDetailSnsItem]() + appendSnsItem(items: &items, iconName: "ic_sns_instagram", url: creatorDetail.instagramUrl) + appendSnsItem(items: &items, iconName: "ic_sns_fancimm", url: creatorDetail.fancimmUrl) + appendSnsItem(items: &items, iconName: "ic_sns_x", url: creatorDetail.xurl) + appendSnsItem(items: &items, iconName: "ic_sns_youtube", url: creatorDetail.youtubeUrl) + appendSnsItem(items: &items, iconName: "ic_sns_kakao", url: creatorDetail.kakaoOpenChatUrl) + return items + } + + var body: some View { + ZStack { + Color.black + .opacity(0.7) + .ignoresSafeArea() + .onTapGesture { + isShowing = false + } + + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { + Spacer() + + Image(closeIconAssetName) + .resizable() + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + .onTapGesture { + isShowing = false + } + } + .padding(.top, 24) + .padding(.trailing, 24) + + VStack(alignment: .leading, spacing: 0) { + if isLoading { + LoadingView() + .frame(maxWidth: .infinity) + .frame(height: 240) + } else if let creatorDetail { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + KFImage(URL(string: creatorDetail.profileImageUrl)) + .cancelOnDisappear(true) + .downsampling(size: CGSize(width: profileImageSize, height: profileImageSize)) + .resizable() + .scaledToFill() + .frame(width: profileImageSize, height: profileImageSize) + .clipped() + .cornerRadius(16) + + Text(creatorDetail.nickname) + .appFont(size: 36, weight: .bold) + .foregroundColor(.white) + .padding(.top, 24) + + VStack(alignment: .leading, spacing: 30) { + detailSection( + title: I18n.MemberChannel.creatorDetailDebut, + value: debutDisplayValue(creatorDetail: creatorDetail) + ) + + detailSection( + title: I18n.MemberChannel.creatorDetailTotalLiveCount, + value: creatorDetail.activitySummary.liveCount.comma() + ) + + detailSection( + title: I18n.MemberChannel.creatorDetailAccumulatedLiveTime, + value: creatorDetail.activitySummary.liveTime.comma() + ) + + detailSection( + title: I18n.MemberChannel.creatorDetailAccumulatedParticipants, + value: creatorDetail.activitySummary.liveContributorCount.comma() + ) + + detailSection( + title: I18n.MemberChannel.creatorDetailRegisteredContentCount, + value: creatorDetail.activitySummary.contentCount.comma() + ) + + if !snsItems.isEmpty { + snsSection(items: snsItems) + } + } + .padding(.top, 30) + } + } + .frame(maxHeight: screenSize().height * 0.72) + } + } + .padding(.horizontal, 24) + .padding(.top, 12) + .padding(.bottom, 24) + } + .background(Color.gray22) + .cornerRadius(8) + .padding(.horizontal, 13.3) + } + } + + @ViewBuilder + private func detailSection(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .appFont(size: 16, weight: .medium) + .foregroundColor(Color(hex: "B0BEC5")) + + Text(value) + .appFont(size: 20, weight: .medium) + .foregroundColor(.white) + } + } + + @ViewBuilder + private func snsSection(items: [CreatorDetailSnsItem]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(I18n.MemberChannel.creatorDetailSns) + .appFont(size: 16, weight: .medium) + .foregroundColor(Color(hex: "B0BEC5")) + + HStack(spacing: 12) { + ForEach(items) { item in + Image(item.iconName) + .resizable() + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + .onTapGesture { + openSnsLink(item.url) + } + } + } + } + } + + private func debutDisplayValue(creatorDetail: GetCreatorDetailResponse) -> String { + let debutDate = creatorDetail.debutDate.trimmingCharacters(in: .whitespacesAndNewlines) + let dday = creatorDetail.dday.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !debutDate.isEmpty, !dday.isEmpty else { + return I18n.MemberChannel.preDebut + } + + return "\(debutDate) (\(dday))" + } + + private func appendSnsItem(items: inout [CreatorDetailSnsItem], iconName: String, url: String) { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmed.isEmpty else { + return + } + + items.append(CreatorDetailSnsItem(iconName: iconName, url: trimmed)) + } + + private func openSnsLink(_ urlString: String) { + guard let url = normalizedUrl(urlString) else { + return + } + + openURL(url) + } + + private func normalizedUrl(_ urlString: String) -> URL? { + let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmed.isEmpty else { + return nil + } + + if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { + return URL(string: trimmed) + } + + return URL(string: "https://\(trimmed)") + } +} + +private struct CreatorDetailSnsItem: Identifiable { + let iconName: String + let url: String + + var id: String { iconName } +} + diff --git a/SodaLive/Sources/Explorer/Profile/Detail/GetCreatorDetailResponse.swift b/SodaLive/Sources/Explorer/Profile/Detail/GetCreatorDetailResponse.swift new file mode 100644 index 0000000..339557d --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/Detail/GetCreatorDetailResponse.swift @@ -0,0 +1,26 @@ +// +// GetCreatorDetailResponse.swift +// SodaLive +// +// Created by klaus on 2/25/26. +// + +struct GetCreatorDetailResponse: Decodable { + let nickname: String + let profileImageUrl: String + let debutDate: String + let dday: String + let activitySummary: CreatorDetailActivitySummary + let instagramUrl: String + let fancimmUrl: String + let xurl: String + let youtubeUrl: String + let kakaoOpenChatUrl: String +} + +struct CreatorDetailActivitySummary: Decodable { + let liveCount: Int + let liveTime: Int + let liveContributorCount: Int + let contentCount: Int +} diff --git a/SodaLive/Sources/Explorer/Profile/UserProfileView.swift b/SodaLive/Sources/Explorer/Profile/UserProfileView.swift index 23adf51..4f01cc9 100644 --- a/SodaLive/Sources/Explorer/Profile/UserProfileView.swift +++ b/SodaLive/Sources/Explorer/Profile/UserProfileView.swift @@ -21,6 +21,7 @@ struct UserProfileView: View { @State private var isShowFollowNotifyDialog: Bool = false @State private var isShowRouletteSettings: Bool = false @State private var isShowMenuSettings: Bool = false + @State private var isShowCreatorDetailDialog: Bool = false @State private var maxCommunityPostHeight: CGFloat? = nil @@ -79,11 +80,13 @@ struct UserProfileView: View { AppState.shared.setAppStep(step: .followerList(userId: creatorProfile.creator.creatorId)) } } else { - VStack(alignment: .leading, spacing: 9.3) { - Text(I18n.MemberChannel.followerCount(creatorProfile.creator.notificationRecipientCount.comma())) - .appFont(size: 16, weight: .medium) - .foregroundColor(Color.white) - } + Text(I18n.MemberChannel.followerCountWithDetail(creatorProfile.creator.notificationRecipientCount.comma())) + .appFont(size: 16, weight: .medium) + .foregroundColor(Color.white) + .onTapGesture { + isShowCreatorDetailDialog = true + viewModel.getCreatorDetail(userId: creatorProfile.creator.creatorId) + } } } .padding(24) @@ -517,6 +520,14 @@ struct UserProfileView: View { if isShowMemberProfilePopup { MemberProfileDialog(isShowing: $isShowMemberProfilePopup, memberId: memberId) } + + if isShowCreatorDetailDialog { + CreatorDetailDialogView( + isShowing: $isShowCreatorDetailDialog, + creatorDetail: viewModel.creatorDetail, + isLoading: viewModel.isCreatorDetailLoading + ) + } } ZStack { diff --git a/SodaLive/Sources/Explorer/Profile/UserProfileViewModel.swift b/SodaLive/Sources/Explorer/Profile/UserProfileViewModel.swift index a44f3c9..311be2d 100644 --- a/SodaLive/Sources/Explorer/Profile/UserProfileViewModel.swift +++ b/SodaLive/Sources/Explorer/Profile/UserProfileViewModel.swift @@ -35,6 +35,8 @@ final class UserProfileViewModel: ObservableObject { @Published var navigationTitle = "채널" @Published private(set) var creatorProfile: GetCreatorProfileResponse? + @Published private(set) var creatorDetail: GetCreatorDetailResponse? + @Published var isCreatorDetailLoading = false @Published private(set) var communityPostList = [GetCommunityPostListResponse]() @Published var isShowShareView = false @@ -97,6 +99,47 @@ final class UserProfileViewModel: ObservableObject { } .store(in: &subscription) } + + func getCreatorDetail(userId: Int) { + creatorDetail = nil + isCreatorDetailLoading = true + + repository.getCreatorDetail(id: userId) + .sink { [weak self] result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + self?.isCreatorDetailLoading = false + } + } receiveValue: { [unowned self] response in + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.creatorDetail = data + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = I18n.Common.commonError + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + + self.isCreatorDetailLoading = false + } + .store(in: &subscription) + } func hidePaymentPopup() { isShowPaymentDialog = false diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 78d2f18..1e8bd88 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -884,6 +884,16 @@ enum I18n { static var followerCount: (String) -> String = { count in pick(ko: "팔로워 \(count)명", en: "\(count) followers", ja: "フォロワー\(count)人") } + static var followerCountWithDetail: (String) -> String = { count in + pick(ko: "팔로워 \(count)명 · 상세정보 >", en: "\(count) followers · Details >", ja: "フォロワー\(count)人 ・ 詳細情報 >") + } + static var creatorDetailDebut: String { pick(ko: "데뷔", en: "Debut", ja: "デビュー") } + static var creatorDetailTotalLiveCount: String { pick(ko: "라이브 총 횟수", en: "Total live sessions", ja: "ライブ総回数") } + static var creatorDetailAccumulatedLiveTime: String { pick(ko: "라이브 누적 시간", en: "Total live time", ja: "ライブ累積時間") } + static var creatorDetailAccumulatedParticipants: String { pick(ko: "라이브 누적 참여자", en: "Total live participants", ja: "ライブ累積参加者") } + static var creatorDetailRegisteredContentCount: String { pick(ko: "등록 콘텐츠 수", en: "Registered contents", ja: "登録コンテンツ数") } + static var creatorDetailSns: String { pick(ko: "SNS", en: "SNS", ja: "SNS") } + static var preDebut: String { pick(ko: "데뷔전", en: "Pre-debut", ja: "デビュー前") } static func channelTitle(_ nickname: String) -> String { pick(ko: "\(nickname)님의 채널", en: "\(nickname)'s channel", ja: "\(nickname)のチャンネル") diff --git a/docs/20260225_크리에이터상세정보다이얼로그추가.md b/docs/20260225_크리에이터상세정보다이얼로그추가.md new file mode 100644 index 0000000..39ea259 --- /dev/null +++ b/docs/20260225_크리에이터상세정보다이얼로그추가.md @@ -0,0 +1,22 @@ +# 2026-02-25 크리에이터 상세정보 다이얼로그 추가 + +## 구현 체크리스트 +- [x] 크리에이터 상세정보 조회 API 연결 (`/explorer/profile/{id}/detail`) +- [x] 크리에이터 상세정보 다이얼로그 UI 구현 (`Sources/Explorer/Profile/Detail`) +- [x] `UserProfileView`의 팔로워 문구를 `팔로워 OO명 · 상세정보 >`로 변경 +- [x] 팔로워 문구 탭 시 상세정보 다이얼로그 표시 연결 +- [x] 국제화(I18n) 키 추가 및 적용 +- [x] 진단/빌드 검증 수행 및 결과 기록 + +## 검증 기록 +- 무엇/왜/어떻게: `UserProfileView`에서 팔로워 문구 탭 시 크리에이터 상세정보를 보여주도록 API/ViewModel/다이얼로그 UI를 연결했다. `Sources/Explorer/Profile/Detail`에 다이얼로그 UI와 상세 응답 모델을 배치하고, 숫자값 comma 표기/데뷔전 처리/SNS 조건부 노출을 반영했다. +- 실행 명령: `pod install` +- 결과: 성공 (의존성 설치 및 workspace 통합 완료) +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build -quiet` +- 결과: 성공 (빌드 완료, 스크립트/대상 선택 관련 경고만 존재) +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build -quiet` +- 결과: 성공 (빌드 완료, 경고만 존재) +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -destination "platform=iOS Simulator,name=iPhone 16" test -quiet` +- 결과: 실패 (`Scheme SodaLive is not currently configured for the test action.` — 스킴 테스트 액션 미구성) +- 실행 명령: `lsp_diagnostics` (수정 파일 대상) +- 결과: 개발 환경 인덱싱에서 외부 모듈(`Kingfisher`, `Moya`, `CombineMoya`) 해석 오류가 발생해 신뢰 가능한 진단을 제공하지 못함. 실제 검증은 `xcodebuild` 빌드 성공으로 대체 확인.