Compare commits
6 Commits
359df77b83
...
9b115f3339
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b115f3339 | ||
|
|
dbf696978f | ||
|
|
c9570fc735 | ||
|
|
4f271a77ee | ||
|
|
caa74c607d | ||
|
|
c38c8656e3 |
@@ -8924,7 +8924,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 3A8QGPMRXM;
|
DEVELOPMENT_TEAM = 3A8QGPMRXM;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -8965,7 +8965,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SodaLive/SodaLive.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"SodaLive/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 3A8QGPMRXM;
|
DEVELOPMENT_TEAM = 3A8QGPMRXM;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class AppState: ObservableObject {
|
|||||||
@Published var eventPopup: EventItem? = nil
|
@Published var eventPopup: EventItem? = nil
|
||||||
@Published var purchasedContentId = 0
|
@Published var purchasedContentId = 0
|
||||||
@Published var purchasedContentOrderType = OrderType.KEEP
|
@Published var purchasedContentOrderType = OrderType.KEEP
|
||||||
|
@Published private(set) var pendingMainChatFilter: MainChatFilter? = nil
|
||||||
|
|
||||||
@Published var isRestartApp = false
|
@Published var isRestartApp = false
|
||||||
@Published var startTab: HomeViewModel.CurrentTab = .home
|
@Published var startTab: HomeViewModel.CurrentTab = .home
|
||||||
@@ -192,6 +193,16 @@ class AppState: ObservableObject {
|
|||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setPendingMainChatFilter(_ filter: MainChatFilter) {
|
||||||
|
pendingMainChatFilter = filter
|
||||||
|
}
|
||||||
|
|
||||||
|
func consumePendingMainChatFilter() -> MainChatFilter? {
|
||||||
|
let filter = pendingMainChatFilter
|
||||||
|
pendingMainChatFilter = nil
|
||||||
|
return filter
|
||||||
|
}
|
||||||
|
|
||||||
// 언어 적용 직후 앱을 소프트 재시작(스플래시 -> 메인)하여 전역 UI를 새로고침
|
// 언어 적용 직후 앱을 소프트 재시작(스플래시 -> 메인)하여 전역 UI를 새로고침
|
||||||
func softRestart() {
|
func softRestart() {
|
||||||
isRestartApp = true
|
isRestartApp = true
|
||||||
|
|||||||
@@ -149,9 +149,6 @@ struct ChatRoomView: View {
|
|||||||
|
|
||||||
if viewModel.showQuotaNoticeView {
|
if viewModel.showQuotaNoticeView {
|
||||||
ChatQuotaNoticeItemView(
|
ChatQuotaNoticeItemView(
|
||||||
onSelectAd: {
|
|
||||||
viewModel.showRewardedAdForChatQuota()
|
|
||||||
},
|
|
||||||
onSelectCan10: {
|
onSelectCan10: {
|
||||||
viewModel.purchaseChatQuota(canOption: .can10)
|
viewModel.purchaseChatQuota(canOption: .can10)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -279,9 +279,16 @@ final class ChatRoomViewModel: ObservableObject {
|
|||||||
|
|
||||||
func showRewardedAdForChatQuota() {
|
func showRewardedAdForChatQuota() {
|
||||||
_Concurrency.Task {
|
_Concurrency.Task {
|
||||||
await YandexRewardedAdManager.shared.showAdIfAvailable(for: .chatRoomQuota) { [weak self] in
|
let isShown = await YandexRewardedAdManager.shared.showAdIfAvailable(for: .chatRoomQuota) { [weak self] in
|
||||||
self?.purchaseChatQuota(chargeType: .ad, canOption: nil)
|
self?.purchaseChatQuota(chargeType: .ad, canOption: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !isShown {
|
||||||
|
await MainActor.run {
|
||||||
|
errorMessage = I18n.Chat.Room.rewardedAdLoadFailed
|
||||||
|
isShowPopup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,31 +9,11 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ChatQuotaNoticeItemView: View {
|
struct ChatQuotaNoticeItemView: View {
|
||||||
|
|
||||||
let onSelectAd: () -> Void
|
|
||||||
let onSelectCan10: () -> Void
|
let onSelectCan10: () -> Void
|
||||||
let onSelectCan20: () -> Void
|
let onSelectCan20: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Button {
|
|
||||||
onSelectAd()
|
|
||||||
} label: {
|
|
||||||
Text(I18n.Chat.Room.quotaAdAction(chatCount: 5))
|
|
||||||
.appFont(size: 18, weight: .bold)
|
|
||||||
.foregroundColor(Color(hex: "263238"))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.background(Color(hex: "FEF8E3"))
|
|
||||||
.cornerRadius(30)
|
|
||||||
.overlay {
|
|
||||||
RoundedRectangle(cornerRadius: 30)
|
|
||||||
.stroke(lineWidth: 1)
|
|
||||||
.foregroundColor(Color(hex: "F7CB50"))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Button {
|
Button {
|
||||||
onSelectCan10()
|
onSelectCan10()
|
||||||
@@ -80,7 +60,6 @@ struct ChatQuotaNoticeItemView: View {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ChatQuotaNoticeItemView(
|
ChatQuotaNoticeItemView(
|
||||||
onSelectAd: {},
|
|
||||||
onSelectCan10: {},
|
onSelectCan10: {},
|
||||||
onSelectCan20: {}
|
onSelectCan20: {}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -300,6 +300,14 @@ enum I18n {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static var rewardedAdLoadFailed: String {
|
||||||
|
pick(
|
||||||
|
ko: "광고를 불러오지 못했어요\n잠시 후 다시 시도해 주세요.",
|
||||||
|
en: "Could not load the ad.\nPlease try again later.",
|
||||||
|
ja: "広告を読み込めませんでした。\nしばらくしてからもう一度お試しください。"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
static var backgroundSelectionTitle: String {
|
static var backgroundSelectionTitle: String {
|
||||||
pick(ko: "배경 이미지 선택", en: "Select background image", ja: "背景画像を選択")
|
pick(ko: "배경 이미지 선택", en: "Select background image", ja: "背景画像を選択")
|
||||||
}
|
}
|
||||||
@@ -3247,9 +3255,9 @@ If you block this user, the following features will be restricted.
|
|||||||
enum HomeFollowing {
|
enum HomeFollowing {
|
||||||
static var emptyStateMessage: String {
|
static var emptyStateMessage: String {
|
||||||
pick(
|
pick(
|
||||||
ko: "팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.",
|
ko: "아직 팔로잉 소식이 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.",
|
||||||
en: "No following updates yet.\nFollow creators you care about.",
|
en: "There are no following updates yet.\nFollow creators you're interested in.",
|
||||||
ja: "フォロー中の新着情報はまだありません。\n気になるクリエイターをフォローしてみましょう。"
|
ja: "まだフォロー中の新着情報がありません。\n気になるクリエイターをフォローしてみましょう。"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3435,6 +3443,7 @@ If you block this user, the following features will be restricted.
|
|||||||
static var following: String { pick(ko: "팔로잉", en: "Following", ja: "フォロー中") }
|
static var following: String { pick(ko: "팔로잉", en: "Following", ja: "フォロー中") }
|
||||||
static var talk: String { pick(ko: "대화하기", en: "Start chat", ja: "会話する") }
|
static var talk: String { pick(ko: "대화하기", en: "Start chat", ja: "会話する") }
|
||||||
static var dm: String { pick(ko: "DM 보내기", en: "Send DM", ja: "DMを送る") }
|
static var dm: String { pick(ko: "DM 보내기", en: "Send DM", ja: "DMを送る") }
|
||||||
|
static var checkDm: String { pick(ko: "DM 확인하기", en: "Check DM", ja: "DMを確認") }
|
||||||
static var uploadCommunityPost: String { pick(ko: "커뮤니티 글 올리기", en: "Upload community post", ja: "コミュニティ投稿を作成") }
|
static var uploadCommunityPost: String { pick(ko: "커뮤니티 글 올리기", en: "Upload community post", ja: "コミュニティ投稿を作成") }
|
||||||
static var uploadAudioContent: String { pick(ko: "오디오 콘텐츠 올리기", en: "Upload audio content", ja: "オーディオコンテンツを投稿") }
|
static var uploadAudioContent: String { pick(ko: "오디오 콘텐츠 올리기", en: "Upload audio content", ja: "オーディオコンテンツを投稿") }
|
||||||
static var createLive: String { pick(ko: "라이브 만들기", en: "Create live", ja: "ライブを作成") }
|
static var createLive: String { pick(ko: "라이브 만들기", en: "Create live", ja: "ライブを作成") }
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ struct CreatorChannelAudioTabView: View {
|
|||||||
.foregroundColor(Color.gray500)
|
.foregroundColor(Color.gray500)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 48)
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 48)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ final class CreatorChannelAudioViewModel: ObservableObject {
|
|||||||
@Published var isShowPopup = false
|
@Published var isShowPopup = false
|
||||||
|
|
||||||
var displayThemes: [CreatorChannelAudioThemeItem] {
|
var displayThemes: [CreatorChannelAudioThemeItem] {
|
||||||
let themes = response?.themes.map {
|
guard let response else { return [] }
|
||||||
|
|
||||||
|
let themes = response.themes.map {
|
||||||
CreatorChannelAudioThemeItem(themeId: $0.themeId, themeName: $0.themeName)
|
CreatorChannelAudioThemeItem(themeId: $0.themeId, themeName: $0.themeName)
|
||||||
} ?? []
|
}
|
||||||
|
|
||||||
return [CreatorChannelAudioThemeItem.all] + themes
|
return [CreatorChannelAudioThemeItem.all] + themes
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ struct CreatorChannelCommunityEmptyView: View {
|
|||||||
.lineSpacing(SodaSpacing.s4)
|
.lineSpacing(SodaSpacing.s4)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 316)
|
.padding(.top, 20)
|
||||||
.padding(.bottom, 120)
|
.padding(.bottom, 120)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Kingfisher
|
|||||||
|
|
||||||
struct CreatorChannelHeaderSection: View {
|
struct CreatorChannelHeaderSection: View {
|
||||||
let creator: CreatorChannelCreatorResponse
|
let creator: CreatorChannelCreatorResponse
|
||||||
|
let isOwnCreatorChannel: Bool
|
||||||
let onTapTalk: () -> Void
|
let onTapTalk: () -> Void
|
||||||
let onTapDm: () -> Void
|
let onTapDm: () -> Void
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ struct CreatorChannelHeaderSection: View {
|
|||||||
CreatorChannelTalkActionSection(
|
CreatorChannelTalkActionSection(
|
||||||
isAiChatAvailable: creator.isAiChatAvailable,
|
isAiChatAvailable: creator.isAiChatAvailable,
|
||||||
isDmAvailable: creator.isDmAvailable,
|
isDmAvailable: creator.isDmAvailable,
|
||||||
|
isOwnCreatorChannel: isOwnCreatorChannel,
|
||||||
onTapTalk: onTapTalk,
|
onTapTalk: onTapTalk,
|
||||||
onTapDm: onTapDm
|
onTapDm: onTapDm
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ struct CreatorChannelView: View {
|
|||||||
if let creator = viewModel.response?.creator {
|
if let creator = viewModel.response?.creator {
|
||||||
CreatorChannelHeaderSection(
|
CreatorChannelHeaderSection(
|
||||||
creator: creator,
|
creator: creator,
|
||||||
|
isOwnCreatorChannel: isOwnCreatorChannel,
|
||||||
onTapTalk: handleTalkTap,
|
onTapTalk: handleTalkTap,
|
||||||
onTapDm: handleDmTap
|
onTapDm: handleDmTap
|
||||||
)
|
)
|
||||||
@@ -557,6 +558,12 @@ struct CreatorChannelView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isOwnCreatorChannel {
|
||||||
|
AppState.shared.setPendingMainChatFilter(.dm)
|
||||||
|
AppState.shared.setAppStep(step: .main)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
AppState.shared.setAppStep(step: .userCreatorChatCreator(creatorId: creatorId))
|
AppState.shared.setAppStep(step: .userCreatorChatCreator(creatorId: creatorId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ struct CreatorChannelDonationEmptyView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 48)
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 48)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ struct CreatorChannelFanTalkEmptyView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 48)
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 48)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
struct CreatorChannelTalkActionSection: View {
|
struct CreatorChannelTalkActionSection: View {
|
||||||
let isAiChatAvailable: Bool
|
let isAiChatAvailable: Bool
|
||||||
let isDmAvailable: Bool
|
let isDmAvailable: Bool
|
||||||
|
let isOwnCreatorChannel: Bool
|
||||||
let onTapTalk: () -> Void
|
let onTapTalk: () -> Void
|
||||||
let onTapDm: () -> Void
|
let onTapDm: () -> Void
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ struct CreatorChannelTalkActionSection: View {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 20, height: 20)
|
.frame(width: 20, height: 20)
|
||||||
|
|
||||||
Text(I18n.CreatorChannelHome.dm)
|
Text(isOwnCreatorChannel ? I18n.CreatorChannelHome.checkDm : I18n.CreatorChannelHome.dm)
|
||||||
.appFont(size: 16, weight: .medium)
|
.appFont(size: 16, weight: .medium)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ struct CreatorChannelLiveTabView: View {
|
|||||||
.foregroundColor(Color.gray500)
|
.foregroundColor(Color.gray500)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 48)
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 48)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ struct CreatorChannelSeriesTabView: View {
|
|||||||
.foregroundColor(Color.gray500)
|
.foregroundColor(Color.gray500)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 48)
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 48)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ struct MainChatView: View {
|
|||||||
let onTapSearch: () -> Void
|
let onTapSearch: () -> Void
|
||||||
|
|
||||||
@StateObject private var viewModel = MainChatViewModel()
|
@StateObject private var viewModel = MainChatViewModel()
|
||||||
|
@StateObject private var appState = AppState.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -28,7 +29,14 @@ struct MainChatView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
.background(Color.black.ignoresSafeArea())
|
.background(Color.black.ignoresSafeArea())
|
||||||
.onAppear { viewModel.fetchFirstPageIfNeeded() }
|
.onAppear {
|
||||||
|
if applyPendingFilterIfNeeded() == false {
|
||||||
|
viewModel.fetchFirstPageIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.valueChanged(value: appState.pendingMainChatFilter) { _ in
|
||||||
|
_ = applyPendingFilterIfNeeded()
|
||||||
|
}
|
||||||
.sodaToast(
|
.sodaToast(
|
||||||
isPresented: $viewModel.isShowPopup,
|
isPresented: $viewModel.isShowPopup,
|
||||||
message: viewModel.errorMessage,
|
message: viewModel.errorMessage,
|
||||||
@@ -89,6 +97,12 @@ struct MainChatView: View {
|
|||||||
AppState.shared.setAppStep(step: .userCreatorChatRoom(roomId: roomId))
|
AppState.shared.setAppStep(step: .userCreatorChatRoom(roomId: roomId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyPendingFilterIfNeeded() -> Bool {
|
||||||
|
guard let filter = appState.consumePendingMainChatFilter() else { return false }
|
||||||
|
viewModel.applyFilter(filter)
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MainChatView_Previews: PreviewProvider {
|
struct MainChatView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ struct MainContentVoiceOnOnlySection: View {
|
|||||||
let action: (() -> Void)?
|
let action: (() -> Void)?
|
||||||
let onTapSeries: (Int) -> Void
|
let onTapSeries: (Int) -> Void
|
||||||
|
|
||||||
|
private let baseDeviceWidth: CGFloat = 402
|
||||||
|
private let maxItemWidth: CGFloat = 163
|
||||||
|
private let maxItemHeight: CGFloat = 230
|
||||||
|
|
||||||
|
private var itemSize: CGSize {
|
||||||
|
let scale = min(1, UIScreen.main.bounds.width / baseDeviceWidth)
|
||||||
|
return CGSize(width: maxItemWidth * scale, height: maxItemHeight * scale)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if !items.isEmpty {
|
if !items.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
|
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
|
||||||
@@ -20,13 +29,13 @@ struct MainContentVoiceOnOnlySection: View {
|
|||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
DownsampledKFImage(
|
DownsampledKFImage(
|
||||||
url: URL(string: item.coverImageUrl),
|
url: URL(string: item.coverImageUrl),
|
||||||
size: CGSize(width: 122, height: 172)
|
size: itemSize
|
||||||
)
|
)
|
||||||
.background(Color.gray800)
|
.background(Color.gray800)
|
||||||
|
|
||||||
originalTag
|
originalTag
|
||||||
}
|
}
|
||||||
.frame(width: 122, height: 172)
|
.frame(width: itemSize.width, height: itemSize.height)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import SwiftUI
|
|||||||
struct MainHomeFollowingChatSection: View {
|
struct MainHomeFollowingChatSection: View {
|
||||||
let recentChats: [ChatRoomListItemResponse]
|
let recentChats: [ChatRoomListItemResponse]
|
||||||
let onTapTitle: () -> Void
|
let onTapTitle: () -> Void
|
||||||
let onTapChatRoom: (Int) -> Void
|
let onTapChatRoom: (Int, String) -> Void
|
||||||
|
|
||||||
init(
|
init(
|
||||||
recentChats: [ChatRoomListItemResponse],
|
recentChats: [ChatRoomListItemResponse],
|
||||||
onTapTitle: @escaping () -> Void = {},
|
onTapTitle: @escaping () -> Void = {},
|
||||||
onTapChatRoom: @escaping (Int) -> Void = { _ in }
|
onTapChatRoom: @escaping (Int, String) -> Void = { _, _ in }
|
||||||
) {
|
) {
|
||||||
self.recentChats = recentChats
|
self.recentChats = recentChats
|
||||||
self.onTapTitle = onTapTitle
|
self.onTapTitle = onTapTitle
|
||||||
@@ -24,7 +24,7 @@ struct MainHomeFollowingChatSection: View {
|
|||||||
LazyHStack(alignment: .top, spacing: SodaSpacing.s8) {
|
LazyHStack(alignment: .top, spacing: SodaSpacing.s8) {
|
||||||
ForEach(recentChats) { chat in
|
ForEach(recentChats) { chat in
|
||||||
MainHomeFollowingChatItemView(chat: chat) {
|
MainHomeFollowingChatItemView(chat: chat) {
|
||||||
onTapChatRoom(chat.roomId)
|
onTapChatRoom(chat.roomId, chat.chatType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ struct MainHomeFollowingEmptyStateView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Text(message)
|
Text(message)
|
||||||
.appFont(.body5)
|
.appFont(.body3)
|
||||||
.foregroundColor(Color.gray500)
|
.foregroundColor(Color.gray500)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ struct MainHomeFollowingView: View {
|
|||||||
let onTapLogin: () -> Void
|
let onTapLogin: () -> Void
|
||||||
let onTapFollowingAll: () -> Void
|
let onTapFollowingAll: () -> Void
|
||||||
let onTapChatTab: () -> Void
|
let onTapChatTab: () -> Void
|
||||||
let onTapChatRoom: (Int) -> Void
|
let onTapChatRoom: (Int, String) -> Void
|
||||||
let onTapSchedule: (CreatorActivityType, Int) -> Void
|
let onTapSchedule: (CreatorActivityType, Int) -> Void
|
||||||
let onTapCommunityPost: (Int) -> Void
|
let onTapCommunityPost: (Int) -> Void
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ struct MainHomeFollowingView: View {
|
|||||||
onTapLogin: @escaping () -> Void = {},
|
onTapLogin: @escaping () -> Void = {},
|
||||||
onTapFollowingAll: @escaping () -> Void = {},
|
onTapFollowingAll: @escaping () -> Void = {},
|
||||||
onTapChatTab: @escaping () -> Void = {},
|
onTapChatTab: @escaping () -> Void = {},
|
||||||
onTapChatRoom: @escaping (Int) -> Void = { _ in },
|
onTapChatRoom: @escaping (Int, String) -> Void = { _, _ in },
|
||||||
onTapSchedule: @escaping (CreatorActivityType, Int) -> Void = { _, _ in },
|
onTapSchedule: @escaping (CreatorActivityType, Int) -> Void = { _, _ in },
|
||||||
onTapCommunityPost: @escaping (Int) -> Void = { _ in }
|
onTapCommunityPost: @escaping (Int) -> Void = { _ in }
|
||||||
) {
|
) {
|
||||||
@@ -61,11 +61,13 @@ struct MainHomeFollowingView: View {
|
|||||||
} else if let response = viewModel.response {
|
} else if let response = viewModel.response {
|
||||||
ScrollView(.vertical, showsIndicators: false) {
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
if !response.followingCreators.isEmpty {
|
||||||
MainHomeFollowingCreatorSection(
|
MainHomeFollowingCreatorSection(
|
||||||
followingCreators: response.followingCreators,
|
followingCreators: response.followingCreators,
|
||||||
onTapCreator: onTapCreator,
|
onTapCreator: onTapCreator,
|
||||||
onTapAll: onTapFollowingAll
|
onTapAll: onTapFollowingAll
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if !response.onAirLives.isEmpty {
|
if !response.onAirLives.isEmpty {
|
||||||
MainHomeFollowingLiveSection(
|
MainHomeFollowingLiveSection(
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ struct MainHomeView: View {
|
|||||||
let onTapFollowingLogin: () -> Void
|
let onTapFollowingLogin: () -> Void
|
||||||
let onTapFollowingAll: () -> Void
|
let onTapFollowingAll: () -> Void
|
||||||
let onTapChatTab: () -> Void
|
let onTapChatTab: () -> Void
|
||||||
let onTapChatRoom: (Int) -> Void
|
let onTapChatRoom: (Int, String) -> Void
|
||||||
let onTapFollowingSchedule: (CreatorActivityType, Int) -> Void
|
let onTapFollowingSchedule: (CreatorActivityType, Int) -> Void
|
||||||
|
|
||||||
@State private var selectedTab: MainHomeTab = .recommendation
|
@State private var selectedTab: MainHomeTab = .recommendation
|
||||||
@@ -125,7 +125,7 @@ struct MainHomeView_Previews: PreviewProvider {
|
|||||||
onTapFollowingLogin: {},
|
onTapFollowingLogin: {},
|
||||||
onTapFollowingAll: {},
|
onTapFollowingAll: {},
|
||||||
onTapChatTab: {},
|
onTapChatTab: {},
|
||||||
onTapChatRoom: { _ in },
|
onTapChatRoom: { _, _ in },
|
||||||
onTapFollowingSchedule: { _, _ in }
|
onTapFollowingSchedule: { _, _ in }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ struct MainView: View {
|
|||||||
.valueChanged(value: appState.pushMessageId) { handlePushMessageId($0) }
|
.valueChanged(value: appState.pushMessageId) { handlePushMessageId($0) }
|
||||||
.valueChanged(value: appState.pushAudioContentId) { handlePushAudioContentId($0) }
|
.valueChanged(value: appState.pushAudioContentId) { handlePushAudioContentId($0) }
|
||||||
.valueChanged(value: appState.pushSeriesId) { handlePushSeriesId($0) }
|
.valueChanged(value: appState.pushSeriesId) { handlePushSeriesId($0) }
|
||||||
|
.valueChanged(value: appState.pendingMainChatFilter) { handlePendingMainChatFilter($0) }
|
||||||
.valueChanged(value: appState.isShowPlayer) { isShowPlayer in
|
.valueChanged(value: appState.isShowPlayer) { isShowPlayer in
|
||||||
guard !isShowPlayer,
|
guard !isShowPlayer,
|
||||||
let pendingExternalNavigationAction = pendingExternalNavigationAction else {
|
let pendingExternalNavigationAction = pendingExternalNavigationAction else {
|
||||||
@@ -408,6 +409,11 @@ struct MainView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handlePendingMainChatFilter(_ filter: MainChatFilter?) {
|
||||||
|
guard filter != nil else { return }
|
||||||
|
viewModel.currentTab = .chat
|
||||||
|
}
|
||||||
|
|
||||||
private func handlePushAudioContentId(_ value: Int) {
|
private func handlePushAudioContentId(_ value: Int) {
|
||||||
guard value > 0 else { return }
|
guard value > 0 else { return }
|
||||||
let contentId = value
|
let contentId = value
|
||||||
@@ -513,10 +519,15 @@ struct MainView: View {
|
|||||||
viewModel.currentTab = .chat
|
viewModel.currentTab = .chat
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleFollowingChatRoomTap(roomId: Int) {
|
private func handleFollowingChatRoomTap(roomId: Int, chatType: String) {
|
||||||
guard roomId > 0 else { return }
|
guard roomId > 0 else { return }
|
||||||
|
|
||||||
|
if chatType == "DM" {
|
||||||
|
appState.setAppStep(step: .userCreatorChatRoom(roomId: roomId))
|
||||||
|
} else {
|
||||||
appState.setAppStep(step: .chatRoom(id: roomId))
|
appState.setAppStep(step: .chatRoom(id: roomId))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func handleFollowingScheduleTap(type: CreatorActivityType, targetId: Int) {
|
private func handleFollowingScheduleTap(type: CreatorActivityType, targetId: Int) {
|
||||||
guard targetId > 0 else { return }
|
guard targetId > 0 else { return }
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
- 여러 화면에서 재사용할 근거가 생기기 전까지 팔로잉 전용 UI는 `SodaLive/Sources/V2/Main/Home/Following/Components/**`에 둔다.
|
- 여러 화면에서 재사용할 근거가 생기기 전까지 팔로잉 전용 UI는 `SodaLive/Sources/V2/Main/Home/Following/Components/**`에 둔다.
|
||||||
- 기존 추천/랭킹 탭의 `TargetType`, Repository, ViewModel, `ApiResponse<T>` 디코딩 패턴을 따른다.
|
- 기존 추천/랭킹 탭의 `TargetType`, Repository, ViewModel, `ApiResponse<T>` 디코딩 패턴을 따른다.
|
||||||
- 홈 상단 `팔로잉` 탭 터치 시 로그인 guard를 먼저 적용한다. 비로그인 상태에서는 탭 전환과 API 호출을 하지 않고 로그인 화면으로 이동한다.
|
- 홈 상단 `팔로잉` 탭 터치 시 로그인 guard를 먼저 적용한다. 비로그인 상태에서는 탭 전환과 API 호출을 하지 않고 로그인 화면으로 이동한다.
|
||||||
- 팔로잉 탭 전체 empty 문구는 `팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.`를 사용한다.
|
- 팔로잉 탭 전체 empty 문구는 `팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.`를 사용하고 `I18n.HomeFollowing.emptyStateMessage`로 다국어 처리한다.
|
||||||
- API 실패 문구는 `팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.`를 사용한다.
|
- API 실패 문구는 `팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.`를 사용한다.
|
||||||
- 섹션별 배열이 비어 있으면 해당 섹션은 숨긴다.
|
- 섹션별 배열이 비어 있으면 해당 섹션은 숨긴다. 팔로잉 크리에이터 섹션도 예외 없이 `followingCreators`가 비어 있으면 `전체` 버튼까지 함께 숨긴다.
|
||||||
- 모든 뷰의 루트 container와 섹션 container에는 불필요한 고정 숫자 width/height를 사용하지 않는다.
|
- 모든 뷰의 루트 container와 섹션 container에는 불필요한 고정 숫자 width/height를 사용하지 않는다.
|
||||||
- 프로필 이미지, 썸네일, 아이콘, tag, 최소 터치 영역처럼 UI 의미상 크기가 정해진 요소에만 고정 크기를 사용한다.
|
- 프로필 이미지, 썸네일, 아이콘, tag, 최소 터치 영역처럼 UI 의미상 크기가 정해진 요소에만 고정 크기를 사용한다.
|
||||||
- 외부 라이브러리는 추가하지 않는다.
|
- 외부 라이브러리는 추가하지 않는다.
|
||||||
@@ -183,15 +183,15 @@
|
|||||||
- `followingCreators`를 가로 스크롤 목록으로 표시한다.
|
- `followingCreators`를 가로 스크롤 목록으로 표시한다.
|
||||||
- 각 creator item은 프로필 이미지와 닉네임을 표시한다.
|
- 각 creator item은 프로필 이미지와 닉네임을 표시한다.
|
||||||
- creator item 탭 시 `onTapCreator(creatorId)`를 호출한다.
|
- creator item 탭 시 `onTapCreator(creatorId)`를 호출한다.
|
||||||
- 목록 마지막에는 항상 `전체` 버튼을 표시한다.
|
- `followingCreators`가 비어 있으면 섹션을 렌더링하지 않는다.
|
||||||
- `전체` 버튼은 `followingCreators`가 비어 있어도 표시한다.
|
- 목록 마지막에는 `followingCreators`가 있을 때만 `전체` 버튼을 표시한다.
|
||||||
- `전체` 버튼 text는 `I18n.HomeFollowing.allButtonTitle`, 색상은 `Color.soda400`을 사용한다.
|
- `전체` 버튼 text는 `I18n.HomeFollowing.allButtonTitle`, 색상은 `Color.soda400`을 사용한다.
|
||||||
- `전체` 버튼 horizontal padding은 16으로 둔다.
|
- `전체` 버튼 horizontal padding은 16으로 둔다.
|
||||||
- `전체` 버튼 높이는 creator item 높이와 동일하게 맞춘다.
|
- `전체` 버튼 높이는 creator item 높이와 동일하게 맞춘다.
|
||||||
- `전체` 버튼 탭 시 `onTapAll()`을 호출한다.
|
- `전체` 버튼 탭 시 `onTapAll()`을 호출한다.
|
||||||
- 검증 기준:
|
- 검증 기준:
|
||||||
- 실행 명령: `rg "struct MainHomeFollowingCreatorSection|ScrollView\\(\\.horizontal|followingCreators|onTapCreator|onTapAll|allButtonTitle|Color.soda400|padding\\(\\.horizontal, 16\\)" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingCreatorSection.swift`
|
- 실행 명령: `rg "struct MainHomeFollowingCreatorSection|ScrollView\\(\\.horizontal|followingCreators|onTapCreator|onTapAll|allButtonTitle|Color.soda400|padding\\(\\.horizontal, 16\\)" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingCreatorSection.swift`
|
||||||
- 기대 결과: 가로 creator 목록과 마지막 `전체` 버튼 요구사항이 확인된다.
|
- 기대 결과: 가로 creator 목록, 비어 있을 때 섹션 숨김, 마지막 `전체` 버튼 요구사항이 확인된다.
|
||||||
|
|
||||||
- [x] **Task 3.2: 전체 팔로잉 목록 진입 연결**
|
- [x] **Task 3.2: 전체 팔로잉 목록 진입 연결**
|
||||||
- 대상 파일:
|
- 대상 파일:
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
- 각 item은 `targetImageUrl`, `targetName`, `lastMessage`, `lastMessageAt`을 표시한다.
|
- 각 item은 `targetImageUrl`, `targetName`, `lastMessage`, `lastMessageAt`을 표시한다.
|
||||||
- `chatType == "DM"`이면 `Direct` 태그를 표시한다.
|
- `chatType == "DM"`이면 `Direct` 태그를 표시한다.
|
||||||
- `lastMessageAt`은 UTC 기준 문자열로 받아 상대 시간 또는 기존 Talk item과 맞는 시간 label로 표시한다.
|
- `lastMessageAt`은 UTC 기준 문자열로 받아 상대 시간 또는 기존 Talk item과 맞는 시간 label로 표시한다.
|
||||||
- item 탭 시 `onTapChatRoom(roomId)`를 호출한다.
|
- item 탭 시 `onTapChatRoom(roomId, chatType)`를 호출한다.
|
||||||
- 검증 기준:
|
- 검증 기준:
|
||||||
- 실행 명령: `rg "struct MainHomeFollowingChatSection|recentChatTitle|ChatRoomListItemResponse|targetImageUrl|targetName|lastMessage|lastMessageAt|chatType == \"DM\"|Direct|onTapChatRoom" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingChatSection.swift`
|
- 실행 명령: `rg "struct MainHomeFollowingChatSection|recentChatTitle|ChatRoomListItemResponse|targetImageUrl|targetName|lastMessage|lastMessageAt|chatType == \"DM\"|Direct|onTapChatRoom" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingChatSection.swift`
|
||||||
- 기대 결과: 최근 대화 item 필드, `Direct` 태그 조건, 채팅방 tap callback이 확인된다.
|
- 기대 결과: 최근 대화 item 필드, `Direct` 태그 조건, 채팅방 tap callback이 확인된다.
|
||||||
@@ -275,12 +275,13 @@
|
|||||||
- 확인: `SodaLive/Sources/App/AppStep.swift`
|
- 확인: `SodaLive/Sources/App/AppStep.swift`
|
||||||
- 확인: `SodaLive/Sources/ContentView.swift`
|
- 확인: `SodaLive/Sources/ContentView.swift`
|
||||||
- 작업 내용:
|
- 작업 내용:
|
||||||
- `MainHomeView`에 `onTapChatRoom: (Int) -> Void` callback을 추가한다.
|
- `MainHomeView`에 `onTapChatRoom: (Int, String) -> Void` callback을 추가한다.
|
||||||
- `MainHomeFollowingView`까지 callback을 전달한다.
|
- `MainHomeFollowingView`까지 callback을 전달한다.
|
||||||
- `MainView`에서 `onTapChatRoom`을 `AppState.shared.setAppStep(step: .chatRoom(id: roomId))`로 구현한다.
|
- `MainView`에서 `chatType == "DM"`이면 `AppState.shared.setAppStep(step: .userCreatorChatRoom(roomId: roomId))`로 구현한다.
|
||||||
|
- `MainView`에서 Direct가 아니면 기존처럼 `AppState.shared.setAppStep(step: .chatRoom(id: roomId))`로 구현한다.
|
||||||
- 검증 기준:
|
- 검증 기준:
|
||||||
- 실행 명령: `rg "onTapChatRoom|chatRoom\\(id:|ChatRoomView" SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift`
|
- 실행 명령: `rg "onTapChatRoom|chatRoom\\(id:|ChatRoomView" SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift`
|
||||||
- 기대 결과: 최근 대화 item tap이 기존 채팅방 화면으로 연결된다.
|
- 기대 결과: 최근 대화 Direct item tap은 `UserCreatorChatRoomView`, Direct가 아닌 item tap은 기존 채팅방 화면으로 연결된다.
|
||||||
|
|
||||||
### Phase 7: 이달의 스케줄 섹션
|
### Phase 7: 이달의 스케줄 섹션
|
||||||
|
|
||||||
@@ -479,7 +480,7 @@
|
|||||||
- 작업 내용:
|
- 작업 내용:
|
||||||
- 비로그인 상태에서 홈 `팔로잉` 탭을 탭하면 로그인 화면으로 이동하는지 확인한다.
|
- 비로그인 상태에서 홈 `팔로잉` 탭을 탭하면 로그인 화면으로 이동하는지 확인한다.
|
||||||
- 로그인 상태에서 홈 `팔로잉` 탭 진입 시 API 호출과 섹션 표시가 동작하는지 확인한다.
|
- 로그인 상태에서 홈 `팔로잉` 탭 진입 시 API 호출과 섹션 표시가 동작하는지 확인한다.
|
||||||
- 팔로잉 크리에이터 섹션 마지막에 `전체` 버튼이 항상 표시되는지 확인한다.
|
- 팔로잉 크리에이터가 있으면 섹션 마지막에 `전체` 버튼이 표시되고, 팔로잉 크리에이터가 없으면 섹션과 `전체` 버튼이 표시되지 않는지 확인한다.
|
||||||
- `전체` 버튼 text color가 `Color.soda400`로 보이는지 확인한다.
|
- `전체` 버튼 text color가 `Color.soda400`로 보이는지 확인한다.
|
||||||
- `CREATOR_RANKING`, `CONTENT_RANKING`, `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`가 타입별 UI로 표시되는지 확인한다.
|
- `CREATOR_RANKING`, `CONTENT_RANKING`, `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`가 타입별 UI로 표시되는지 확인한다.
|
||||||
- 루트/섹션 container가 고정 width/height 때문에 작은 화면에서 깨지지 않는지 확인한다.
|
- 루트/섹션 container가 고정 width/height 때문에 작은 화면에서 깨지지 않는지 확인한다.
|
||||||
@@ -514,6 +515,23 @@
|
|||||||
- 실행 명령: `rg "onTapCommunityPost|handleRecommendationCommunityPostTap|creatorChannelCommunityPostDetail|CommunityPostCard" SodaLive/Sources/V2/Main/Home/Following SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift`
|
- 실행 명령: `rg "onTapCommunityPost|handleRecommendationCommunityPostTap|creatorChannelCommunityPostDetail|CommunityPostCard" SodaLive/Sources/V2/Main/Home/Following SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift`
|
||||||
- 기대 결과: 최근 소식 `COMMUNITY_POST` card tap이 기존 커뮤니티 게시글 상세 route로 연결된다.
|
- 기대 결과: 최근 소식 `COMMUNITY_POST` card tap이 기존 커뮤니티 게시글 상세 route로 연결된다.
|
||||||
|
|
||||||
|
### Phase 14: 팔로잉 크리에이터 빈 섹션 후속 정리
|
||||||
|
|
||||||
|
- [x] **Task 14.1: 팔로잉 크리에이터 빈 섹션과 전체 버튼 숨김**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `docs/20260630_메인_홈_팔로잉_탭/prd.md`
|
||||||
|
- 수정: `docs/20260630_메인_홈_팔로잉_탭/plan-task.md`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift`
|
||||||
|
- 확인: `SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 전체 empty 문구가 `I18n.HomeFollowing.emptyStateMessage`로 다국어 처리되어 있음을 확인한다.
|
||||||
|
- 기존 문서의 “크리에이터가 없어도 `전체` 버튼 표시” 요구를 “크리에이터가 있을 때만 섹션과 `전체` 버튼 표시”로 갱신한다.
|
||||||
|
- `MainHomeFollowingView`에서 `response.followingCreators`가 비어 있으면 `MainHomeFollowingCreatorSection`을 렌더링하지 않는다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "emptyStateMessage|팔로잉 소식이 아직 없어요|No following updates yet|フォロー中の新着情報" SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
- 실행 명령: `rg "if !response\\.followingCreators\\.isEmpty|MainHomeFollowingCreatorSection" SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift`
|
||||||
|
- 기대 결과: empty 문구의 ko/en/ja 다국어 처리와 팔로잉 크리에이터 빈 배열 시 섹션 렌더링 차단이 확인된다.
|
||||||
|
|
||||||
## 검증 기록
|
## 검증 기록
|
||||||
|
|
||||||
- 2026-06-30: `docs/agent-guides/documentation-policy.md`, `build-test-verification.md`, `code-style.md`를 확인해 계획 문서 경로, Phase/checklist 형식, 검증 명령 기준을 반영했다.
|
- 2026-06-30: `docs/agent-guides/documentation-policy.md`, `build-test-verification.md`, `code-style.md`를 확인해 계획 문서 경로, Phase/checklist 형식, 검증 명령 기준을 반영했다.
|
||||||
@@ -609,3 +627,8 @@
|
|||||||
- 2026-07-12: `git diff --check -- SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift SodaLive/Sources/V2/Main/Home/MainHomeView.swift docs/20260630_메인_홈_팔로잉_탭/prd.md docs/20260630_메인_홈_팔로잉_탭/plan-task.md` 실행 결과 출력 없이 종료되어 변경 파일 공백 오류가 없음을 확인했다.
|
- 2026-07-12: `git diff --check -- SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift SodaLive/Sources/V2/Main/Home/MainHomeView.swift docs/20260630_메인_홈_팔로잉_탭/prd.md docs/20260630_메인_홈_팔로잉_탭/plan-task.md` 실행 결과 출력 없이 종료되어 변경 파일 공백 오류가 없음을 확인했다.
|
||||||
- 2026-07-12: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataFollowingCommunityPost" build`는 로컬에 team `3A8QGPMRXM`용 iOS Development signing certificate이 없어 코드 서명 단계에서 실패했다.
|
- 2026-07-12: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataFollowingCommunityPost" build`는 로컬에 team `3A8QGPMRXM`용 iOS Development signing certificate이 없어 코드 서명 단계에서 실패했다.
|
||||||
- 2026-07-12: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -destination "generic/platform=iOS Simulator" -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataFollowingCommunityPostSimulator" CODE_SIGNING_ALLOWED=NO build`는 의존성 컴파일 중 600초 제한으로 `BUILD INTERRUPTED`되어 완료 여부를 확인하지 못했다.
|
- 2026-07-12: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -destination "generic/platform=iOS Simulator" -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataFollowingCommunityPostSimulator" CODE_SIGNING_ALLOWED=NO build`는 의존성 컴파일 중 600초 제한으로 `BUILD INTERRUPTED`되어 완료 여부를 확인하지 못했다.
|
||||||
|
- 2026-07-13: 사용자 요청에 따라 전체 empty 문구가 `I18n.HomeFollowing.emptyStateMessage`로 ko/en/ja 다국어 처리되어 있음을 `rg "emptyStateMessage|팔로잉 소식이 아직 없어요|No following updates yet|フォロー中の新着情報" SodaLive/Sources/I18n/I18n.swift`로 확인했다.
|
||||||
|
- 2026-07-13: `MainHomeFollowingView`에서 `response.followingCreators`가 비어 있으면 `MainHomeFollowingCreatorSection`을 렌더링하지 않도록 수정해, 팔로잉 크리에이터가 없을 때 상단 `전체` 버튼도 표시되지 않게 했다.
|
||||||
|
- 2026-07-13: PRD와 plan-task의 기존 “크리에이터가 없어도 `전체` 버튼 표시” 요구사항을 “크리에이터가 있을 때만 섹션과 `전체` 버튼 표시”로 갱신했다.
|
||||||
|
- 2026-07-13: `git diff --check -- SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift docs/20260630_메인_홈_팔로잉_탭/prd.md docs/20260630_메인_홈_팔로잉_탭/plan-task.md` 실행 결과 출력 없이 종료되어 변경 파일 공백 오류가 없음을 확인했다.
|
||||||
|
- 2026-07-13: 샌드박스 안의 `xcodebuild`는 Xcode/CoreSimulator/DerivedData/SwiftPM 캐시 접근 제한으로 실패했으나, 승인 후 샌드박스 밖에서 `xcodebuild -workspace SodaLive.xcworkspace -scheme SodaLive -configuration Debug -destination generic/platform=iOS\ Simulator -derivedDataPath /private/tmp/SodaLiveDerivedDataFollowingEmpty CODE_SIGNING_ALLOWED=NO build`를 실행해 `BUILD SUCCEEDED`를 확인했다. 기존 duplicate build file/dependency scan warning과 `LiveRoomViewModel`의 unused variable warning은 남아 있으나 컴파일 오류는 없었다.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Figma 전체 화면 기준 섹션 순서는 아래를 따른다.
|
|||||||
- 기존 V2 공용 컴포넌트와 추천/랭킹 탭 패턴을 우선 재사용한다.
|
- 기존 V2 공용 컴포넌트와 추천/랭킹 탭 패턴을 우선 재사용한다.
|
||||||
- 홈 상단 `팔로잉` 탭 터치 시 로그인 guard를 먼저 적용해 로그인된 사용자만 탭으로 이동한다.
|
- 홈 상단 `팔로잉` 탭 터치 시 로그인 guard를 먼저 적용해 로그인된 사용자만 탭으로 이동한다.
|
||||||
- `isLoginRequired == true`이면 팔로잉 기반 콘텐츠 대신 로그인 필요 상태를 표시하고, 사용자가 로그인 화면으로 이동할 수 있어야 한다.
|
- `isLoginRequired == true`이면 팔로잉 기반 콘텐츠 대신 로그인 필요 상태를 표시하고, 사용자가 로그인 화면으로 이동할 수 있어야 한다.
|
||||||
- 팔로잉 크리에이터 섹션의 가로 목록 마지막 item에는 항상 `전체` 버튼을 추가한다.
|
- 팔로잉 크리에이터가 있을 때만 팔로잉 크리에이터 섹션을 표시하고, 가로 목록 마지막 item에는 `전체` 버튼을 추가한다.
|
||||||
- 최근 소식은 `FollowingNewsType`별로 다른 View를 표시한다.
|
- 최근 소식은 `FollowingNewsType`별로 다른 View를 표시한다.
|
||||||
- 각 섹션은 이후 `plan-task.md` 작성 시 Figma Design 기준 섹션당 1개의 Phase로 나눌 수 있도록 요구사항을 분리한다.
|
- 각 섹션은 이후 `plan-task.md` 작성 시 Figma Design 기준 섹션당 1개의 Phase로 나눌 수 있도록 요구사항을 분리한다.
|
||||||
- 뷰 전체는 반드시 필요한 경우를 제외하고 고정 숫자 width/height를 갖지 않는다.
|
- 뷰 전체는 반드시 필요한 경우를 제외하고 고정 숫자 width/height를 갖지 않는다.
|
||||||
@@ -350,8 +350,8 @@ Figma 참조:
|
|||||||
- `followingCreators`를 가로 스크롤 목록으로 표시한다.
|
- `followingCreators`를 가로 스크롤 목록으로 표시한다.
|
||||||
- 각 item은 프로필 이미지와 크리에이터 닉네임을 표시한다.
|
- 각 item은 프로필 이미지와 크리에이터 닉네임을 표시한다.
|
||||||
- 크리에이터 item 탭 시 `creatorId`로 기존 크리에이터 상세 화면에 진입한다.
|
- 크리에이터 item 탭 시 `creatorId`로 기존 크리에이터 상세 화면에 진입한다.
|
||||||
- 목록 마지막 item에는 항상 `전체` 버튼을 추가한다.
|
- `followingCreators`가 비어 있으면 팔로잉 크리에이터 섹션을 표시하지 않는다.
|
||||||
- `전체` 버튼은 서버 데이터가 비어 있어도 표시한다.
|
- 목록 마지막 item에는 `followingCreators`가 있을 때만 `전체` 버튼을 추가한다.
|
||||||
- `전체` 버튼의 width는 버튼이 차지하는 콘텐츠 크기 기준으로 잡고, 고정 화면 width를 강제하지 않는다.
|
- `전체` 버튼의 width는 버튼이 차지하는 콘텐츠 크기 기준으로 잡고, 고정 화면 width를 강제하지 않는다.
|
||||||
- `전체` 버튼 height는 크리에이터 item 높이와 동일하게 표시한다.
|
- `전체` 버튼 height는 크리에이터 item 높이와 동일하게 표시한다.
|
||||||
- `전체` 버튼은 `paddingHorizontal = 16`을 적용한다.
|
- `전체` 버튼은 `paddingHorizontal = 16`을 적용한다.
|
||||||
@@ -375,12 +375,12 @@ Figma 참조:
|
|||||||
- `recentChats`가 비어 있으면 섹션을 숨긴다.
|
- `recentChats`가 비어 있으면 섹션을 숨긴다.
|
||||||
- 섹션 타이틀은 `최근 대화`를 사용한다.
|
- 섹션 타이틀은 `최근 대화`를 사용한다.
|
||||||
- `ChatRoomListItemResponse.roomId`를 item id와 채팅방 진입 id로 사용한다.
|
- `ChatRoomListItemResponse.roomId`를 item id와 채팅방 진입 id로 사용한다.
|
||||||
- `chatType == "DM"`이면 `Direct` 태그를 표시한다.
|
- `chatType == "DM"`이면 `Direct` 태그를 표시하고, item 탭 시 `UserCreatorChatRoomView(roomId:)` 진입 흐름을 사용한다.
|
||||||
- `targetName`은 채팅방 제목으로 표시한다.
|
- `targetName`은 채팅방 제목으로 표시한다.
|
||||||
- `targetImageUrl`은 채팅방 이미지로 표시한다.
|
- `targetImageUrl`은 채팅방 이미지로 표시한다.
|
||||||
- `lastMessage`는 최근 메시지 preview로 표시한다.
|
- `lastMessage`는 최근 메시지 preview로 표시한다.
|
||||||
- `lastMessageAt`은 UTC 기준 문자열로 받아 디바이스 timezone 기준 상대 시간 또는 시간 label로 표시한다.
|
- `lastMessageAt`은 UTC 기준 문자열로 받아 디바이스 timezone 기준 상대 시간 또는 시간 label로 표시한다.
|
||||||
- item 탭 시 `roomId`로 기존 채팅방 진입 흐름을 사용한다.
|
- Direct 태그가 없는 item 탭은 `roomId`로 기존 채팅방 진입 흐름을 사용한다.
|
||||||
- 채팅방 진입에 로그인 guard가 필요하면 기존 메인/채팅 탭의 guard 패턴을 재사용한다.
|
- 채팅방 진입에 로그인 guard가 필요하면 기존 메인/채팅 탭의 guard 패턴을 재사용한다.
|
||||||
|
|
||||||
### 7.6 이달의 스케줄 섹션
|
### 7.6 이달의 스케줄 섹션
|
||||||
@@ -541,7 +541,8 @@ Figma 참조:
|
|||||||
- `isLoginRequired == false`이면 Figma 순서대로 팔로잉 크리에이터, On Air, 최근 대화, 이달의 스케줄, 최근 소식 섹션이 표시된다.
|
- `isLoginRequired == false`이면 Figma 순서대로 팔로잉 크리에이터, On Air, 최근 대화, 이달의 스케줄, 최근 소식 섹션이 표시된다.
|
||||||
- 비어 있는 섹션은 표시되지 않는다.
|
- 비어 있는 섹션은 표시되지 않는다.
|
||||||
- 모든 섹션 데이터가 비어 있으면 전체 empty state가 표시된다.
|
- 모든 섹션 데이터가 비어 있으면 전체 empty state가 표시된다.
|
||||||
- 팔로잉 크리에이터 가로 목록 마지막에는 항상 `전체` 버튼이 표시된다.
|
- 팔로잉 크리에이터가 있으면 가로 목록 마지막에 `전체` 버튼이 표시된다.
|
||||||
|
- 팔로잉 크리에이터가 없으면 팔로잉 크리에이터 섹션과 `전체` 버튼은 표시되지 않는다.
|
||||||
- `전체` 버튼은 크리에이터 item 높이와 동일하고, horizontal padding 16, `Color.soda400` 텍스트를 사용한다.
|
- `전체` 버튼은 크리에이터 item 높이와 동일하고, horizontal padding 16, `Color.soda400` 텍스트를 사용한다.
|
||||||
- 최근 소식의 `CREATOR_RANKING`은 `creatorRanking` payload로 랭킹 소식 카드가 표시된다.
|
- 최근 소식의 `CREATOR_RANKING`은 `creatorRanking` payload로 랭킹 소식 카드가 표시된다.
|
||||||
- 최근 소식의 `CONTENT_RANKING`은 `contentRanking` payload로 랭킹 소식 카드가 표시된다.
|
- 최근 소식의 `CONTENT_RANKING`은 `contentRanking` payload로 랭킹 소식 카드가 표시된다.
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
- 생성: `SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioViewModel.swift`
|
- 생성: `SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioViewModel.swift`
|
||||||
- 작업 내용:
|
- 작업 내용:
|
||||||
- 상태는 loading, response, filtered `audioContents`, selectedSort, selectedTheme, page, size, hasNext, hasLoaded, error toast를 둔다.
|
- 상태는 loading, response, filtered `audioContents`, selectedSort, selectedTheme, page, size, hasNext, hasLoaded, error toast를 둔다.
|
||||||
- `displayThemes`에서 전체 항목을 맨 앞에 합성한다.
|
- `displayThemes`에서 서버 `themes` 조회 완료 후 전체 항목을 맨 앞에 합성한다.
|
||||||
- 정렬/테마 변경 시 첫 페이지부터 다시 조회한다.
|
- 정렬/테마 변경 시 첫 페이지부터 다시 조회한다.
|
||||||
- `duration`이 없는 콘텐츠는 `visibleAudioContents`에 포함하지 않는다.
|
- `duration`이 없는 콘텐츠는 `visibleAudioContents`에 포함하지 않는다.
|
||||||
- 검증 기준:
|
- 검증 기준:
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ data class CreatorChannelAudioThemeResponse(
|
|||||||
- Kotlin `Long`은 기존 V2 모델 관례대로 Swift `Int`로 선언한다.
|
- Kotlin `Long`은 기존 V2 모델 관례대로 Swift `Int`로 선언한다.
|
||||||
|
|
||||||
### 5.2 Theme filter
|
### 5.2 Theme filter
|
||||||
- 서버 응답 `themes` 앞에 클라이언트 전용 전체 항목을 추가한다.
|
- 서버 응답 `themes` 조회가 완료된 뒤 맨 앞에 클라이언트 전용 전체 항목을 추가한다.
|
||||||
- 서버 응답 `themeId == nil`이면 전체가 선택된 상태여야 한다.
|
- 서버 응답 `themeId == nil`이면 전체가 선택된 상태여야 한다.
|
||||||
- 테마 선택 시 첫 페이지부터 다시 조회한다.
|
- 테마 선택 시 첫 페이지부터 다시 조회한다.
|
||||||
- 전체 선택 시 `themeId`를 보내지 않는다.
|
- 전체 선택 시 `themeId`를 보내지 않는다.
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ Swift 모델은 기존 V2 관례에 맞춰 `Decodable` 구조체로 선언한다
|
|||||||
- `추천 오디오`는 2열 grid로 표시한다.
|
- `추천 오디오`는 2열 grid로 표시한다.
|
||||||
- 텍스트가 길면 한 줄 또는 Figma 기준 line limit로 말줄임 처리한다.
|
- 텍스트가 길면 한 줄 또는 Figma 기준 line limit로 말줄임 처리한다.
|
||||||
- 카드/row의 루트 레이아웃은 가능한 부모 width, grid column, aspect ratio 기반으로 구성한다.
|
- 카드/row의 루트 레이아웃은 가능한 부모 width, grid column, aspect ratio 기반으로 구성한다.
|
||||||
|
- `오직 보이스온에서만!` 섹션 item은 Figma `24:6745` 기준 device width가 402 이상이면 `163x230`으로 표시하고, 402 미만이면 `deviceWidth / 402` 비율로 동일하게 축소한다.
|
||||||
|
|
||||||
## 9. Technical Constraints
|
## 9. Technical Constraints
|
||||||
- 신규 기능 파일은 `SodaLive/Sources/V2/Main/Content/Recommendation/**` 하위에 둔다.
|
- 신규 기능 파일은 `SodaLive/Sources/V2/Main/Content/Recommendation/**` 하위에 둔다.
|
||||||
@@ -161,6 +162,7 @@ Swift 모델은 기존 V2 관례에 맞춰 `Decodable` 구조체로 선언한다
|
|||||||
- API 응답의 구현 대상 배열이 각 Figma 섹션에 매핑된다.
|
- API 응답의 구현 대상 배열이 각 Figma 섹션에 매핑된다.
|
||||||
- `추천 시리즈`, `#키워드의 오디오` 섹션은 구현되지 않는다.
|
- `추천 시리즈`, `#키워드의 오디오` 섹션은 구현되지 않는다.
|
||||||
- `오직 보이스온에서만!` 섹션은 `ic_series_original` + `img_new_only` 조합으로 `ONLY` 표시를 노출한다.
|
- `오직 보이스온에서만!` 섹션은 `ic_series_original` + `img_new_only` 조합으로 `ONLY` 표시를 노출한다.
|
||||||
|
- `오직 보이스온에서만!` 섹션 item 크기는 402 이상 device width에서 `163x230`이고, 402 미만 device width에서는 같은 비율로 축소된다.
|
||||||
- 오디오 item 탭 시 기존 content detail guard 흐름을 통해 상세로 이동한다.
|
- 오디오 item 탭 시 기존 content detail guard 흐름을 통해 상세로 이동한다.
|
||||||
- `AudioBannerResponse.seriesId`와 `OriginalSeriesResponse.seriesId` 탭 시 `seriesDetail`로 이동한다.
|
- `AudioBannerResponse.seriesId`와 `OriginalSeriesResponse.seriesId` 탭 시 `seriesDetail`로 이동한다.
|
||||||
- `AudioBannerResponse.link`는 deep link 우선 처리 후, deep link가 아니면 외부 브라우저로 열린다.
|
- `AudioBannerResponse.link`는 deep link 우선 처리 후, deep link가 아니면 외부 브라우저로 열린다.
|
||||||
@@ -177,3 +179,4 @@ Swift 모델은 기존 V2 관례에 맞춰 `Decodable` 구조체로 선언한다
|
|||||||
- 2026-07-05: 사용자 확인으로 댓글 preview bubble에는 nickname을 표시하지 않고, 배너 `link`는 deep link 우선 후 외부 브라우저로 열며, `seriesId`는 `seriesDetail` route로 이동하도록 확정했다.
|
- 2026-07-05: 사용자 확인으로 댓글 preview bubble에는 nickname을 표시하지 않고, 배너 `link`는 deep link 우선 후 외부 브라우저로 열며, `seriesId`는 `seriesDetail` route로 이동하도록 확정했다.
|
||||||
- 2026-07-08: Figma `24:6738` 기준으로 콘텐츠 탭 title bar가 홈 탭 로고/bell 구성이 아니라 좌측 고정 title `콘텐츠`와 우측 `ic_bar_cash`/`ic_bar_search`/`ic_bar_storage` 구성을 사용하도록 요구사항을 보완했다.
|
- 2026-07-08: Figma `24:6738` 기준으로 콘텐츠 탭 title bar가 홈 탭 로고/bell 구성이 아니라 좌측 고정 title `콘텐츠`와 우측 `ic_bar_cash`/`ic_bar_search`/`ic_bar_storage` 구성을 사용하도록 요구사항을 보완했다.
|
||||||
- 2026-07-12: 요청에 따라 `최근 댓글이 많은 오디오` 섹션 타이틀을 `크리에이터의 댓글 많은 오디오`로 변경했다.
|
- 2026-07-12: 요청에 따라 `최근 댓글이 많은 오디오` 섹션 타이틀을 `크리에이터의 댓글 많은 오디오`로 변경했다.
|
||||||
|
- 2026-07-12: Figma `24:6745` 기준으로 `오직 보이스온에서만!` 섹션 item 크기를 402 이상 device width에서는 `163x230`, 402 미만 device width에서는 `deviceWidth / 402` 비율 축소로 확정했다.
|
||||||
|
|||||||
@@ -467,3 +467,4 @@
|
|||||||
- 2026-07-12 코드 리뷰 결과 반영: 백그라운드 상태와 `openRoom` 성공 여부를 별도 gate로 관리해 지연된 REST 응답과 예약된 재연결이 백그라운드에서 socket을 다시 열지 않게 했다. 새로 관측된 내 서버 텍스트 메시지는 로컬 pending/failed 메시지와 1:1로 재동기화하고, WebSocket의 public 명령과 delegate/receive/send callback에서 발생하는 mutable state 접근을 main thread로 직렬화했다. DM 채팅과 관계없는 기존 `print(error)` 삭제 2건은 원복했다. `git diff --check HEAD`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, 제거 API/STOMP 정적 검색을 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath /tmp/SodaLiveCodeReviewDerivedData CODE_SIGNING_ALLOWED=NO build` 및 `SodaLive-dev` 동일 명령이 `** BUILD SUCCEEDED **`로 완료됐다. 실제 서버 연동 수동 QA는 Task 6.4에 남겨 두었다.
|
- 2026-07-12 코드 리뷰 결과 반영: 백그라운드 상태와 `openRoom` 성공 여부를 별도 gate로 관리해 지연된 REST 응답과 예약된 재연결이 백그라운드에서 socket을 다시 열지 않게 했다. 새로 관측된 내 서버 텍스트 메시지는 로컬 pending/failed 메시지와 1:1로 재동기화하고, WebSocket의 public 명령과 delegate/receive/send callback에서 발생하는 mutable state 접근을 main thread로 직렬화했다. DM 채팅과 관계없는 기존 `print(error)` 삭제 2건은 원복했다. `git diff --check HEAD`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, 제거 API/STOMP 정적 검색을 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath /tmp/SodaLiveCodeReviewDerivedData CODE_SIGNING_ALLOWED=NO build` 및 `SodaLive-dev` 동일 명령이 `** BUILD SUCCEEDED **`로 완료됐다. 실제 서버 연동 수동 QA는 Task 6.4에 남겨 두었다.
|
||||||
- 2026-07-12 최신 메시지 REST 재동기화 보완: `JOINED` 후 `syncLatestMessages` 완료까지 Loading을 유지하고, 요청 실패·API 실패 응답·디코딩 오류를 `applyRestFailure` 토스트 경로로 전달하도록 수정했다. `git diff --check HEAD`가 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug CODE_SIGNING_ALLOWED=NO build` 및 `SodaLive-dev` 동일 명령이 `** BUILD SUCCEEDED **`로 완료됐다.
|
- 2026-07-12 최신 메시지 REST 재동기화 보완: `JOINED` 후 `syncLatestMessages` 완료까지 Loading을 유지하고, 요청 실패·API 실패 응답·디코딩 오류를 `applyRestFailure` 토스트 경로로 전달하도록 수정했다. `git diff --check HEAD`가 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug CODE_SIGNING_ALLOWED=NO build` 및 `SodaLive-dev` 동일 명령이 `** BUILD SUCCEEDED **`로 완료됐다.
|
||||||
- 2026-07-12 추가 코드 리뷰 결과 반영: `JOIN_ROOM` 전송 오류를 즉시 JOIN 실패 event로 전달하고, 15초 동안 `JOINED`가 없으면 현재 socket을 닫은 뒤 기존 최대 3회 재시도 정책을 적용하도록 보완했다. ACK 유실 재동기화 시 서버 `createdAt`이 로컬 pending 생성 시각보다 최대 5분 이전인 경우까지 동일 메시지 후보로 허용했다. `git diff --check HEAD`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`가 통과했고, `xcodebuild -quiet -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath /tmp/SodaLiveReviewCurrent CODE_SIGNING_ALLOWED=NO build` 및 `SodaLive-dev` 동일 명령이 종료 코드 0으로 완료됐다. 실제 서버 연동 수동 QA는 Task 6.4에 남겨 두었다.
|
- 2026-07-12 추가 코드 리뷰 결과 반영: `JOIN_ROOM` 전송 오류를 즉시 JOIN 실패 event로 전달하고, 15초 동안 `JOINED`가 없으면 현재 socket을 닫은 뒤 기존 최대 3회 재시도 정책을 적용하도록 보완했다. ACK 유실 재동기화 시 서버 `createdAt`이 로컬 pending 생성 시각보다 최대 5분 이전인 경우까지 동일 메시지 후보로 허용했다. `git diff --check HEAD`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`가 통과했고, `xcodebuild -quiet -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath /tmp/SodaLiveReviewCurrent CODE_SIGNING_ALLOWED=NO build` 및 `SodaLive-dev` 동일 명령이 종료 코드 0으로 완료됐다. 실제 서버 연동 수동 QA는 Task 6.4에 남겨 두었다.
|
||||||
|
- 2026-07-12 크리에이터 채널 후속 UX 수정: 크리에이터 채널 각 탭 empty state 문구의 상단 여백을 `20`으로 조정해 중앙 배치가 아닌 상단 배치로 변경했다. 본인 채널의 DM 버튼은 `DM 확인하기`로 표시하고, tap 시 `AppState.pendingMainChatFilter(.dm)`를 통해 메인 대화 탭의 DM 필터로 이동하도록 반영했다. 타인 채널의 `DM 보내기` 및 creatorId 기반 DM 채팅방 진입은 유지했다.
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
- UI는 기존 AI 채팅방과 거의 동일하게 유지하되, 헤더에서 `Character / Clone` 표시, 보유 캔 UI, 우측 더보기 버튼을 제거한다.
|
- UI는 기존 AI 채팅방과 거의 동일하게 유지하되, 헤더에서 `Character / Clone` 표시, 보유 캔 UI, 우측 더보기 버튼을 제거한다.
|
||||||
- 크리에이터 채널의 기존 `handleTalkTap`은 계속 AI 캐릭터 채팅 진입으로 유지한다.
|
- 크리에이터 채널의 기존 `handleTalkTap`은 계속 AI 캐릭터 채팅 진입으로 유지한다.
|
||||||
- 지난 범위에서 구현하지 않은 `DM 보내기` 버튼을 추가하고, 이 버튼을 터치하면 `creatorId` 기반 DM 생성/조회 후 DM 채팅방으로 이동한다.
|
- 지난 범위에서 구현하지 않은 `DM 보내기` 버튼을 추가하고, 이 버튼을 터치하면 `creatorId` 기반 DM 생성/조회 후 DM 채팅방으로 이동한다.
|
||||||
|
- 단, 본인 채널의 DM 버튼은 `DM 확인하기`로 표시하고 메인 대화 탭의 DM 필터로 이동한다.
|
||||||
- `VOICE`는 REST 통신 계층만 구현하고, 이번 범위에서는 음성 메시지 송신 UI, 수신 음성 메시지 표시 UI, 음성 재생 UI를 구현하지 않는다.
|
- `VOICE`는 REST 통신 계층만 구현하고, 이번 범위에서는 음성 메시지 송신 UI, 수신 음성 메시지 표시 UI, 음성 재생 UI를 구현하지 않는다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user