11 Commits

59 changed files with 2399 additions and 625 deletions

View File

@@ -74,6 +74,7 @@ class AppState: ObservableObject {
@Published var isShowErrorPopup = false @Published var isShowErrorPopup = false
@Published var errorMessage = "" @Published var errorMessage = ""
@Published private var pendingContentSettingsGuideMessage: String? = nil
@Published var liveDetailSheet: LiveDetailSheetState? = nil @Published var liveDetailSheet: LiveDetailSheetState? = nil
private func syncStepWithNavigationPath() { private func syncStepWithNavigationPath() {
@@ -181,6 +182,16 @@ class AppState: ObservableObject {
pendingCommunityCommentPostId = 0 pendingCommunityCommentPostId = 0
} }
func setPendingContentSettingsGuideMessage(_ message: String) {
pendingContentSettingsGuideMessage = message
}
func consumePendingContentSettingsGuideMessage() -> String? {
let message = pendingContentSettingsGuideMessage
pendingContentSettingsGuideMessage = nil
return message
}
// ( -> ) UI // ( -> ) UI
func softRestart() { func softRestart() {
isRestartApp = true isRestartApp = true

View File

@@ -75,6 +75,9 @@ final class AppViewModel: ObservableObject {
UserDefaults.set(data.isAuth, forKey: .auth) UserDefaults.set(data.isAuth, forKey: .auth)
UserDefaults.set(data.role.rawValue, forKey: .role) UserDefaults.set(data.role.rawValue, forKey: .role)
UserDefaults.set(data.auditionNotice ?? false, forKey: .isAuditionNotification) UserDefaults.set(data.auditionNotice ?? false, forKey: .isAuditionNotification)
UserDefaults.set(data.countryCode, forKey: .countryCode)
UserDefaults.set(data.isAdultContentVisible, forKey: .isAdultContentVisible)
UserDefaults.set(data.contentType, forKey: .contentPreference)
if data.followingChannelLiveNotice == nil && data.followingChannelUploadContentNotice == nil && data.messageNotice == nil { if data.followingChannelLiveNotice == nil && data.followingChannelUploadContentNotice == nil && data.messageNotice == nil {
AppState.shared.isShowNotificationSettingsDialog = true AppState.shared.isShowNotificationSettingsDialog = true
} }

View File

@@ -41,7 +41,14 @@ struct ChatTabView: View {
AppState.shared.setAppStep(step: .login) AppState.shared.setAppStep(step: .login)
return return
} }
if auth == false {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
if isKoreanCountry && auth == false {
pendingAction = { pendingAction = {
AppState.shared AppState.shared
.setAppStep(step: .characterDetail(characterId: characterId)) .setAppStep(step: .characterDetail(characterId: characterId))
@@ -49,9 +56,21 @@ struct ChatTabView: View {
isShowAuthConfirmView = true isShowAuthConfirmView = true
return return
} }
if !UserDefaults.isAdultContentVisible() {
pendingAction = nil
moveToContentSettingsWithGuideToast()
return
}
AppState.shared.setAppStep(step: .characterDetail(characterId: characterId)) AppState.shared.setAppStep(step: .characterDetail(characterId: characterId))
} }
private func moveToContentSettingsWithGuideToast() {
AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide)
AppState.shared.setAppStep(step: .contentViewSettings)
}
private func handleCharacterSelection() { private func handleCharacterSelection() {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { guard !trimmed.isEmpty else {

View File

@@ -9,7 +9,7 @@ import Foundation
import Moya import Moya
enum ContentApi { enum ContentApi {
case getAudioContentList(userId: Int, categoryId: Int, isAdultContentVisible: Bool, page: Int, size: Int, sort: ContentListViewModel.Sort) case getAudioContentList(userId: Int, categoryId: Int, page: Int, size: Int, sort: ContentListViewModel.Sort)
case getAudioContentDetail(audioContentId: Int) case getAudioContentDetail(audioContentId: Int)
case likeContent(request: PutAudioContentLikeRequest) case likeContent(request: PutAudioContentLikeRequest)
case registerComment(request: RegisterAudioContentCommentRequest) case registerComment(request: RegisterAudioContentCommentRequest)
@@ -25,51 +25,51 @@ enum ContentApi {
case getNewContentUploadCreatorList case getNewContentUploadCreatorList
case getMainBannerList case getMainBannerList
case getMainOrderList case getMainOrderList
case getNewContentOfTheme(theme: String, isAdultContentVisible: Bool, contentType: ContentType) case getNewContentOfTheme(theme: String)
case getCurationList(isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) case getCurationList(page: Int, size: Int)
case donation(request: AudioContentDonationRequest) case donation(request: AudioContentDonationRequest)
case modifyComment(request: ModifyCommentRequest) case modifyComment(request: ModifyCommentRequest)
case getNewContentThemeList(isAdultContentVisible: Bool, contentType: ContentType) case getNewContentThemeList
case getNewContentAllOfTheme(isFree: Bool, theme: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) case getNewContentAllOfTheme(isFree: Bool, theme: String, page: Int, size: Int)
case getAudioContentListByCurationId(curationId: Int, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int, sort: ContentCurationViewModel.Sort) case getAudioContentListByCurationId(curationId: Int, page: Int, size: Int, sort: ContentCurationViewModel.Sort)
case getContentRanking(isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int, sortType: String) case getContentRanking(page: Int, size: Int, sortType: String)
case getContentRankingSortType case getContentRankingSortType
case pinContent(contentId: Int) case pinContent(contentId: Int)
case unpinContent(contentId: Int) case unpinContent(contentId: Int)
case getAudioContentByTheme(themeId: Int, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int, sort: ContentAllByThemeViewModel.Sort) case getAudioContentByTheme(themeId: Int, page: Int, size: Int, sort: ContentAllByThemeViewModel.Sort)
case generateUrl(contentId: Int) case generateUrl(contentId: Int)
case getContentMainHome(isAdultContentVisible: Bool, contentType: ContentType) case getContentMainHome
case getPopularContentByCreator(creatorId: Int, isAdultContentVisible: Bool, contentType: ContentType) case getPopularContentByCreator(creatorId: Int)
case getContentMainHomeContentRanking(isAdultContentVisible: Bool, contentType: ContentType, sortType: String) case getContentMainHomeContentRanking(sortType: String)
case getContentMainSeries(isAdultContentVisible: Bool, contentType: ContentType) case getContentMainSeries
case getRecommendSeriesListByGenre(genreId: Int, isAdultContentVisible: Bool, contentType: ContentType) case getRecommendSeriesListByGenre(genreId: Int)
case getRecommendSeriesByCreator(creatorId: Int, isAdultContentVisible: Bool, contentType: ContentType) case getRecommendSeriesByCreator(creatorId: Int)
case getCompletedSeries(isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) case getCompletedSeries(page: Int, size: Int)
case getContentMainContent(isAdultContentVisible: Bool, contentType: ContentType) case getContentMainContent
case getContentMainNewContentOfTheme(theme: String, isAdultContentVisible: Bool, contentType: ContentType) case getContentMainNewContentOfTheme(theme: String)
case getDailyContentRanking(sortType: String, isAdultContentVisible: Bool, contentType: ContentType) case getDailyContentRanking(sortType: String)
case getRecommendContentByTag(tag: String, contentType: ContentType) case getRecommendContentByTag(tag: String)
case getContentMainContentPopularContentByCreator(creatorId: Int, isAdultContentVisible: Bool, contentType: ContentType) case getContentMainContentPopularContentByCreator(creatorId: Int)
case getContentMainAlarm(isAdultContentVisible: Bool, contentType: ContentType) case getContentMainAlarm
case getContentMainAlarmAll(theme: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) case getContentMainAlarmAll(theme: String, page: Int, size: Int)
case getContentMainAsmr(isAdultContentVisible: Bool, contentType: ContentType) case getContentMainAsmr
case getPopularAsmrContentByCreator(creatorId: Int, isAdultContentVisible: Bool, contentType: ContentType) case getPopularAsmrContentByCreator(creatorId: Int)
case getContentMainReplay(isAdultContentVisible: Bool, contentType: ContentType) case getContentMainReplay
case getPopularReplayContentByCreator(creatorId: Int, isAdultContentVisible: Bool, contentType: ContentType) case getPopularReplayContentByCreator(creatorId: Int)
case getContentMainFree(isAdultContentVisible: Bool, contentType: ContentType) case getContentMainFree
case getIntroduceCreatorList(isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) case getIntroduceCreatorList(page: Int, size: Int)
case getNewFreeContentOfTheme(theme: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) case getNewFreeContentOfTheme(theme: String, page: Int, size: Int)
case getPopularFreeContentByCreator(creatorId: Int, isAdultContentVisible: Bool, contentType: ContentType) case getPopularFreeContentByCreator(creatorId: Int)
case getAllAudioContents(isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int, isFree: Bool?, isPointAvailableOnly: Bool?, sortType: ContentAllViewModel.Sort = .NEWEST, theme: String? = nil) case getAllAudioContents(page: Int, size: Int, isFree: Bool?, isPointAvailableOnly: Bool?, sortType: ContentAllViewModel.Sort = .NEWEST, theme: String? = nil)
case getAudioContentActiveThemeList(isAdultContentVisible: Bool, contentType: ContentType, isFree: Bool?, isPointAvailableOnly: Bool?) case getAudioContentActiveThemeList(isFree: Bool?, isPointAvailableOnly: Bool?)
} }
extension ContentApi: TargetType { extension ContentApi: TargetType {
@@ -145,7 +145,7 @@ extension ContentApi: TargetType {
case .getNewContentAllOfTheme: case .getNewContentAllOfTheme:
return "/audio-content/main/new/all" return "/audio-content/main/new/all"
case .getAudioContentListByCurationId(let curationId, _, _, _, _, _): case .getAudioContentListByCurationId(let curationId, _, _, _):
return "/audio-content/curation/\(curationId)" return "/audio-content/curation/\(curationId)"
case .getContentRanking: case .getContentRanking:
@@ -160,7 +160,7 @@ extension ContentApi: TargetType {
case .unpinContent(let contentId): case .unpinContent(let contentId):
return "/audio-content/unpin-at-the-top/\(contentId)" return "/audio-content/unpin-at-the-top/\(contentId)"
case .getAudioContentByTheme(let themeId, _, _, _, _, _): case .getAudioContentByTheme(let themeId, _, _, _):
return "/audio-content/theme/\(themeId)/content" return "/audio-content/theme/\(themeId)/content"
case .generateUrl(let contentId): case .generateUrl(let contentId):
@@ -273,11 +273,10 @@ extension ContentApi: TargetType {
var task: Moya.Task { var task: Moya.Task {
switch self { switch self {
case .getAudioContentList(let userId, let categoryId, let isAdultContentVisible, let page, let size, let sort): case .getAudioContentList(let userId, let categoryId, let page, let size, let sort):
let parameters = [ let parameters = [
"creator-id": userId, "creator-id": userId,
"category-id": categoryId, "category-id": categoryId,
"isAdultContentVisible": isAdultContentVisible,
"page": page - 1, "page": page - 1,
"size": size, "size": size,
"sort-type": sort "sort-type": sort
@@ -339,11 +338,9 @@ extension ContentApi: TargetType {
case .deleteAudioContent: case .deleteAudioContent:
return .requestPlain return .requestPlain
case .getNewContentOfTheme(let theme, let isAdultContentVisible, let contentType): case .getNewContentOfTheme(let theme):
let parameters = [ let parameters = [
"theme": theme, "theme": theme
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
@@ -354,30 +351,21 @@ extension ContentApi: TargetType {
case .modifyComment(let request): case .modifyComment(let request):
return .requestJSONEncodable(request) return .requestJSONEncodable(request)
case .getNewContentThemeList(let isAdultContentVisible, let contentType): case .getNewContentThemeList:
let parameters = [ return .requestPlain
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) case .getNewContentAllOfTheme(let isFree, let theme, let page, let size):
case .getNewContentAllOfTheme(let isFree, let theme, let isAdultContentVisible, let contentType, let page, let size):
let parameters = [ let parameters = [
"isFree": isFree, "isFree": isFree,
"theme": theme, "theme": theme,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1, "page": page - 1,
"size": size "size": size
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getAudioContentListByCurationId(_, let isAdultContentVisible, let contentType, let page, let size, let sort): case .getAudioContentListByCurationId(_, let page, let size, let sort):
let parameters = [ let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1, "page": page - 1,
"size": size, "size": size,
"sort-type": sort "sort-type": sort
@@ -385,10 +373,8 @@ extension ContentApi: TargetType {
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentRanking(let isAdultContentVisible, let contentType, let page, let size, let sortType): case .getContentRanking(let page, let size, let sortType):
let parameters = [ let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1, "page": page - 1,
"size": size, "size": size,
"sort-type": sortType "sort-type": sortType
@@ -399,10 +385,8 @@ extension ContentApi: TargetType {
case .getContentRankingSortType: case .getContentRankingSortType:
return .requestPlain return .requestPlain
case .getCurationList(let isAdultContentVisible, let contentType, let page, let size): case .getCurationList(let page, let size):
let parameters = [ let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1, "page": page - 1,
"size": size "size": size
] as [String : Any] ] as [String : Any]
@@ -412,10 +396,8 @@ extension ContentApi: TargetType {
case .pinContent, .unpinContent: case .pinContent, .unpinContent:
return .requestPlain return .requestPlain
case .getAudioContentByTheme(_, let isAdultContentVisible, let contentType, let page, let size, let sort): case .getAudioContentByTheme(_, let page, let size, let sort):
let parameters = [ let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1, "page": page - 1,
"size": size, "size": size,
"sort-type": sort "sort-type": sort
@@ -423,141 +405,109 @@ extension ContentApi: TargetType {
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentMainHome(let isAdultContentVisible, let contentType), case .getContentMainHome,
.getContentMainSeries(let isAdultContentVisible, let contentType), .getContentMainSeries,
.getContentMainContent(let isAdultContentVisible, let contentType), .getContentMainContent,
.getContentMainAlarm(let isAdultContentVisible, let contentType), .getContentMainAlarm,
.getContentMainAsmr(let isAdultContentVisible, let contentType), .getContentMainAsmr,
.getContentMainReplay(let isAdultContentVisible, let contentType), .getContentMainReplay,
.getContentMainFree(let isAdultContentVisible, let contentType): .getContentMainFree:
return .requestPlain
case .getRecommendSeriesListByGenre(let genreId):
let parameters = [ let parameters = [
"isAdultContentVisible": isAdultContentVisible, "genreId": genreId
"contentType": contentType
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getRecommendSeriesListByGenre(let genreId, let isAdultContentVisible, let contentType): case .getPopularContentByCreator(let creatorId):
let parameters = [ let parameters = [
"genreId": genreId, "creatorId": creatorId
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getPopularContentByCreator(let creatorId, let isAdultContentVisible, let contentType): case .getContentMainHomeContentRanking(let sortType):
let parameters = [ let parameters = [
"creatorId": creatorId, "sort-type": sortType
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentMainHomeContentRanking(let isAdultContentVisible, let contentType, let sortType): case .getRecommendSeriesByCreator(let creatorId):
let parameters = [ let parameters = [
"sort-type": sortType, "creatorId": creatorId
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getRecommendSeriesByCreator(let creatorId, let isAdultContentVisible, let contentType): case .getContentMainNewContentOfTheme(let theme):
let parameters = [ let parameters = [
"creatorId": creatorId, "theme": theme
"isAdultContentVisible": isAdultContentVisible, ] as [String : Any]
"contentType": contentType return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getDailyContentRanking(let sortType):
let parameters = [
"sort-type": sortType
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getRecommendContentByTag(let tag):
let parameters = [
"tag": tag
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentMainContentPopularContentByCreator(let creatorId):
let parameters = [
"creatorId": creatorId
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentMainNewContentOfTheme(let theme, let isAdultContentVisible, let contentType): case .getNewFreeContentOfTheme(let theme, let page, let size):
let parameters = [ let parameters = [
"theme": theme, "theme": theme,
"isAdultContentVisible": isAdultContentVisible, "page": page - 1,
"contentType": contentType "size": size
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getDailyContentRanking(let sortType, let isAdultContentVisible, let contentType): case .getContentMainAlarmAll(let theme, let page, let size):
let parameters = [
"sort-type": sortType,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getRecommendContentByTag(let tag, let contentType):
let parameters = [
"tag": tag,
"contentType": contentType
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentMainContentPopularContentByCreator(let creatorId, let isAdultContentVisible, let contentType):
let parameters = [
"creatorId": creatorId,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getNewFreeContentOfTheme(let theme, let isAdultContentVisible, let contentType, let page, let size):
let parameters = [ let parameters = [
"theme": theme, "theme": theme,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1, "page": page - 1,
"size": size "size": size
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentMainAlarmAll(let theme, let isAdultContentVisible, let contentType, let page, let size): case .getIntroduceCreatorList(let page, let size):
let parameters = [ let parameters = [
"theme": theme,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1, "page": page - 1,
"size": size "size": size
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getIntroduceCreatorList(let isAdultContentVisible, let contentType, let page, let size): case .getPopularAsmrContentByCreator(let creatorId),
.getPopularReplayContentByCreator(let creatorId),
.getPopularFreeContentByCreator(let creatorId):
let parameters = [ let parameters = [
"isAdultContentVisible": isAdultContentVisible, "creatorId": creatorId
"contentType": contentType,
"page": page - 1,
"size": size
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getPopularAsmrContentByCreator(let creatorId, let isAdultContentVisible, let contentType), case .getCompletedSeries(let page, let size):
.getPopularReplayContentByCreator(let creatorId, let isAdultContentVisible, let contentType),
.getPopularFreeContentByCreator(let creatorId, let isAdultContentVisible, let contentType):
let parameters = [ let parameters = [
"creatorId": creatorId,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getCompletedSeries(let isAdultContentVisible, let contentType, let page, let size):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1, "page": page - 1,
"size": size "size": size
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getAllAudioContents(let isAdultContentVisible, let contentType, let page, let size, let isFree, let isPointAvailableOnly, let sortType, let theme): case .getAllAudioContents(let page, let size, let isFree, let isPointAvailableOnly, let sortType, let theme):
var parameters = [ var parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"sort-type": sortType, "sort-type": sortType,
"page": page - 1, "page": page - 1,
"size": size "size": size
@@ -577,11 +527,8 @@ extension ContentApi: TargetType {
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getAudioContentActiveThemeList(let isAdultContentVisible, let contentType, let isFree, let isPointAvailableOnly): case .getAudioContentActiveThemeList(let isFree, let isPointAvailableOnly):
var parameters = [ var parameters = [String : Any]()
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
] as [String : Any]
if let isFree = isFree { if let isFree = isFree {
parameters["isFree"] = isFree parameters["isFree"] = isFree

View File

@@ -20,7 +20,6 @@ final class ContentRepository {
.getAudioContentList( .getAudioContentList(
userId: userId, userId: userId,
categoryId: categoryId, categoryId: categoryId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
page: page, page: page,
size: size, size: size,
sort: sort) sort: sort)
@@ -89,19 +88,13 @@ final class ContentRepository {
func getNewContentOfTheme(theme: String) -> AnyPublisher<Response, MoyaError> { func getNewContentOfTheme(theme: String) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(
.getNewContentOfTheme( .getNewContentOfTheme(theme: theme)
theme: theme,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
) )
} }
func getCurationList(page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { func getCurationList(page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(
.getCurationList( .getCurationList(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page, page: page,
size: size size: size
) )
@@ -117,12 +110,7 @@ final class ContentRepository {
} }
func getNewContentThemeList() -> AnyPublisher<Response, MoyaError> { func getNewContentThemeList() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(.getNewContentThemeList)
.getNewContentThemeList(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
} }
func getNewContentAllOfTheme(isFree: Bool, theme: String, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { func getNewContentAllOfTheme(isFree: Bool, theme: String, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
@@ -130,8 +118,6 @@ final class ContentRepository {
.getNewContentAllOfTheme( .getNewContentAllOfTheme(
isFree: isFree, isFree: isFree,
theme: theme, theme: theme,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page, page: page,
size: size size: size
) )
@@ -142,8 +128,6 @@ final class ContentRepository {
return api.requestPublisher( return api.requestPublisher(
.getAudioContentListByCurationId( .getAudioContentListByCurationId(
curationId: curationId, curationId: curationId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page, page: page,
size: size, size: size,
sort: sort sort: sort
@@ -158,8 +142,6 @@ final class ContentRepository {
func getContentRanking(page: Int, size: Int, sortType: String = "매출") -> AnyPublisher<Response, MoyaError> { func getContentRanking(page: Int, size: Int, sortType: String = "매출") -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(
.getContentRanking( .getContentRanking(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page, page: page,
size: size, size: size,
sortType: sortType sortType: sortType
@@ -183,8 +165,6 @@ final class ContentRepository {
return api.requestPublisher( return api.requestPublisher(
.getAudioContentByTheme( .getAudioContentByTheme(
themeId: themeId, themeId: themeId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page, page: page,
size: size, size: size,
sort: sort sort: sort
@@ -206,8 +186,6 @@ final class ContentRepository {
) -> AnyPublisher<Response, MoyaError> { ) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(
.getAllAudioContents( .getAllAudioContents(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page, page: page,
size: size, size: size,
isFree: isFree, isFree: isFree,
@@ -221,8 +199,6 @@ final class ContentRepository {
func getAudioContentActiveThemeList(isFree: Bool, isPointAvailableOnly: Bool) -> AnyPublisher<Response, MoyaError> { func getAudioContentActiveThemeList(isFree: Bool, isPointAvailableOnly: Bool) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(
.getAudioContentActiveThemeList( .getAudioContentActiveThemeList(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
isFree: isFree, isFree: isFree,
isPointAvailableOnly: isPointAvailableOnly isPointAvailableOnly: isPointAvailableOnly
) )

View File

@@ -24,6 +24,14 @@ struct ContentCreateView: View {
@State private var isShowSelectTimeView = false @State private var isShowSelectTimeView = false
var body: some View { var body: some View {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
let isAdultContentVisible = UserDefaults.isAdultContentVisible()
let shouldShowAdultSetting = isAdultContentVisible && (!isKoreanCountry || UserDefaults.bool(forKey: .auth))
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
GeometryReader { proxy in GeometryReader { proxy in
ZStack { ZStack {
@@ -448,34 +456,36 @@ struct ContentCreateView: View {
.padding(.top, 26.7) .padding(.top, 26.7)
.padding(.horizontal, 13.3) .padding(.horizontal, 13.3)
VStack(spacing: 13.3) { if shouldShowAdultSetting {
Text("연령 제한") VStack(spacing: 13.3) {
.appFont(size: 16.7, weight: .bold) Text("연령 제한")
.foregroundColor(Color.grayee) .appFont(size: 16.7, weight: .bold)
.frame(maxWidth: .infinity, alignment: .leading) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 13.3) { HStack(spacing: 13.3) {
SelectButtonView(title: I18n.CreateContent.allAges, isChecked: !viewModel.isAdult) { SelectButtonView(title: I18n.CreateContent.allAges, isChecked: !viewModel.isAdult) {
if viewModel.isAdult { if viewModel.isAdult {
viewModel.isAdult = false viewModel.isAdult = false
}
}
SelectButtonView(title: I18n.CreateContent.over19, isChecked: viewModel.isAdult) {
if !viewModel.isAdult {
viewModel.isAdult = true
}
} }
} }
SelectButtonView(title: I18n.CreateContent.over19, isChecked: viewModel.isAdult) { Text("성인콘텐츠를 전체관람가로 등록할 시 발생하는 법적 책임은 회사와 상관없이 콘텐츠를 등록한 본인에게 있습니다.\n콘텐츠 내용은 물론 제목도 19금 여부를 체크해 주시기 바랍니다.")
if !viewModel.isAdult { .appFont(size: 13.3, weight: .medium)
viewModel.isAdult = true .foregroundColor(Color.mainRed3)
} .frame(maxWidth: .infinity, alignment: .leading)
} .padding(.top, 13.3)
} }
.padding(.top, 26.7)
Text("성인콘텐츠를 전체관람가로 등록할 시 발생하는 법적 책임은 회사와 상관없이 콘텐츠를 등록한 본인에게 있습니다.\n콘텐츠 내용은 물론 제목도 19금 여부를 체크해 주시기 바랍니다.") .padding(.horizontal, 13.3)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.mainRed3)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 13.3)
} }
.padding(.top, 26.7)
.padding(.horizontal, 13.3)
VStack(spacing: 13.3) { VStack(spacing: 13.3) {
Text("댓글 가능 여부") Text("댓글 가능 여부")

View File

@@ -9,11 +9,11 @@ import Foundation
import Moya import Moya
enum SeriesMainApi { enum SeriesMainApi {
case fetchHome(isAdultContentVisible: Bool, contentType: ContentType) case fetchHome
case getRecommendSeriesList(isAdultContentVisible: Bool, contentType: ContentType) case getRecommendSeriesList
case getDayOfWeekSeriesList(dayOfWeek: SeriesPublishedDaysOfWeek, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) case getDayOfWeekSeriesList(dayOfWeek: SeriesPublishedDaysOfWeek, page: Int, size: Int)
case getGenreList(isAdultContentVisible: Bool, contentType: ContentType) case getGenreList
case getSeriesListByGenre(genreId: Int, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) case getSeriesListByGenre(genreId: Int, page: Int, size: Int)
} }
extension SeriesMainApi: TargetType { extension SeriesMainApi: TargetType {
@@ -39,46 +39,27 @@ extension SeriesMainApi: TargetType {
var task: Moya.Task { var task: Moya.Task {
switch self { switch self {
case .fetchHome(let isAdultContentVisible, let contentType): case .fetchHome:
let parameters = [ return .requestPlain
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) case .getRecommendSeriesList:
return .requestPlain
case .getRecommendSeriesList(let isAdultContentVisible, let contentType): case .getDayOfWeekSeriesList(let dayOfWeek, let page, let size):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getDayOfWeekSeriesList(let dayOfWeek, let isAdultContentVisible, let contentType, let page, let size):
let parameters = [ let parameters = [
"dayOfWeek": dayOfWeek, "dayOfWeek": dayOfWeek,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1, "page": page - 1,
"size": size "size": size
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getGenreList(let isAdultContentVisible, let contentType): case .getGenreList:
let parameters = [ return .requestPlain
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) case .getSeriesListByGenre(let genreId, let page, let size):
case .getSeriesListByGenre(let genreId, let isAdultContentVisible, let contentType, let page, let size):
let parameters = [ let parameters = [
"genreId": genreId, "genreId": genreId,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1, "page": page - 1,
"size": size "size": size
] as [String : Any] ] as [String : Any]

View File

@@ -14,29 +14,17 @@ class SeriesMainRepository {
private let api = MoyaProvider<SeriesMainApi>() private let api = MoyaProvider<SeriesMainApi>()
func fetchHome() -> AnyPublisher<Response, MoyaError> { func fetchHome() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(.fetchHome)
.fetchHome(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
} }
func getRecommendSeriesList() -> AnyPublisher<Response, MoyaError> { func getRecommendSeriesList() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(.getRecommendSeriesList)
.getRecommendSeriesList(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
} }
func getDayOfWeekSeriesList(dayOfWeek: SeriesPublishedDaysOfWeek, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { func getDayOfWeekSeriesList(dayOfWeek: SeriesPublishedDaysOfWeek, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(
.getDayOfWeekSeriesList( .getDayOfWeekSeriesList(
dayOfWeek: dayOfWeek, dayOfWeek: dayOfWeek,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page, page: page,
size: size size: size
) )
@@ -44,20 +32,13 @@ class SeriesMainRepository {
} }
func getGenreList() -> AnyPublisher<Response, MoyaError> { func getGenreList() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(.getGenreList)
.getGenreList(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
)
)
} }
func getSeriesListByGenre(genreId: Int, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { func getSeriesListByGenre(genreId: Int, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(
.getSeriesListByGenre( .getSeriesListByGenre(
genreId: genreId, genreId: genreId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page, page: page,
size: size size: size
) )

View File

@@ -9,10 +9,10 @@ import Foundation
import Moya import Moya
enum SeriesApi { enum SeriesApi {
case getSeriesList(creatorId: Int?, isOriginal: Bool, isCompleted: Bool, sortType: SeriesListAllViewModel.SeriesSortType, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) case getSeriesList(creatorId: Int?, isOriginal: Bool, isCompleted: Bool, sortType: SeriesListAllViewModel.SeriesSortType, page: Int, size: Int)
case getSeriesDetail(seriesId: Int, isAdultContentVisible: Bool) case getSeriesDetail(seriesId: Int)
case getSeriesContentList(seriesId: Int, isAdultContentVisible: Bool, page: Int, size: Int, sortType: SeriesListAllViewModel.SeriesSortType) case getSeriesContentList(seriesId: Int, page: Int, size: Int, sortType: SeriesListAllViewModel.SeriesSortType)
case getRecommendSeriesList(isAdultContentVisible: Bool, contentType: ContentType) case getRecommendSeriesList
} }
extension SeriesApi: TargetType { extension SeriesApi: TargetType {
@@ -25,10 +25,10 @@ extension SeriesApi: TargetType {
case .getSeriesList: case .getSeriesList:
return "/audio-content/series" return "/audio-content/series"
case .getSeriesDetail(let seriesId, _): case .getSeriesDetail(let seriesId):
return "/audio-content/series/\(seriesId)" return "/audio-content/series/\(seriesId)"
case .getSeriesContentList(let seriesId, _, _, _, _): case .getSeriesContentList(let seriesId, _, _, _):
return "/audio-content/series/\(seriesId)/content" return "/audio-content/series/\(seriesId)/content"
case .getRecommendSeriesList: case .getRecommendSeriesList:
@@ -45,11 +45,9 @@ extension SeriesApi: TargetType {
var task: Moya.Task { var task: Moya.Task {
switch self { switch self {
case .getSeriesList(let creatorId, let isOriginal, let isCompleted, let sortType, let isAdultContentVisible, let contentType, let page, let size): case .getSeriesList(let creatorId, let isOriginal, let isCompleted, let sortType, let page, let size):
var parameters = [ var parameters = [
"sortType": sortType, "sortType": sortType,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"isOriginal": isOriginal, "isOriginal": isOriginal,
"isCompleted": isCompleted, "isCompleted": isCompleted,
"page": page - 1, "page": page - 1,
@@ -62,21 +60,14 @@ extension SeriesApi: TargetType {
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getSeriesDetail(_, let isAdultContentVisible): case .getSeriesDetail:
let parameters = ["isAdultContentVisible": isAdultContentVisible] as [String : Any] return .requestPlain
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getRecommendSeriesList(let isAdultContentVisible, let contentType): case .getRecommendSeriesList:
return .requestPlain
case .getSeriesContentList(_, let page, let size, let sortType):
let parameters = [ let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getSeriesContentList(_, let isAdultContentVisible, let page, let size, let sortType):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"page": page - 1, "page": page - 1,
"size": size, "size": size,
"sortType": sortType "sortType": sortType

View File

@@ -20,8 +20,6 @@ class SeriesRepository {
isOriginal: isOriginal, isOriginal: isOriginal,
isCompleted: isCompleted, isCompleted: isCompleted,
sortType: sortType, sortType: sortType,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page, page: page,
size: size size: size
) )
@@ -29,19 +27,13 @@ class SeriesRepository {
} }
func getSeriesDetail(seriesId: Int) -> AnyPublisher<Response, MoyaError> { func getSeriesDetail(seriesId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(.getSeriesDetail(seriesId: seriesId))
.getSeriesDetail(
seriesId: seriesId,
isAdultContentVisible: UserDefaults.isAdultContentVisible()
)
)
} }
func getSeriesContentList(seriesId: Int, page: Int, size: Int, sortType: SeriesListAllViewModel.SeriesSortType) -> AnyPublisher<Response, MoyaError> { func getSeriesContentList(seriesId: Int, page: Int, size: Int, sortType: SeriesListAllViewModel.SeriesSortType) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(
.getSeriesContentList( .getSeriesContentList(
seriesId: seriesId, seriesId: seriesId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
page: page, page: page,
size: size, size: size,
sortType: sortType sortType: sortType
@@ -50,11 +42,6 @@ class SeriesRepository {
} }
func getRecommendSeriesList() -> AnyPublisher<Response, MoyaError> { func getRecommendSeriesList() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(.getRecommendSeriesList)
.getRecommendSeriesList(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
} }
} }

View File

@@ -12,7 +12,7 @@ enum ExplorerApi {
case getCreatorRank case getCreatorRank
case getExplorer case getExplorer
case searchChannel(channel: String) case searchChannel(channel: String)
case getCreatorProfile(userId: Int, isAdultContentVisible: Bool) case getCreatorProfile(userId: Int)
case getCreatorDetail(userId: Int) case getCreatorDetail(userId: Int)
case getFollowerList(userId: Int, page: Int, size: Int) case getFollowerList(userId: Int, page: Int, size: Int)
case getCreatorProfileCheers(userId: Int, page: Int, size: Int) case getCreatorProfileCheers(userId: Int, page: Int, size: Int)
@@ -40,7 +40,7 @@ extension ExplorerApi: TargetType {
case .searchChannel: case .searchChannel:
return "/explorer/search/channel" return "/explorer/search/channel"
case .getCreatorProfile(let userId, _): case .getCreatorProfile(let userId):
return "/explorer/profile/\(userId)" return "/explorer/profile/\(userId)"
case .getCreatorDetail(let userId): case .getCreatorDetail(let userId):
@@ -90,8 +90,8 @@ extension ExplorerApi: TargetType {
case .searchChannel(let channel): case .searchChannel(let channel):
return .requestParameters(parameters: ["channel" : channel], encoding: URLEncoding.queryString) return .requestParameters(parameters: ["channel" : channel], encoding: URLEncoding.queryString)
case .getCreatorProfile(_, let isAdultContentVisible): case .getCreatorProfile:
let parameters = ["isAdultContentVisible": isAdultContentVisible, "timezone": TimeZone.current.identifier] as [String: Any] let parameters = ["timezone": TimeZone.current.identifier] as [String: Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getFollowerList(_, let page, let size): case .getFollowerList(_, let page, let size):

View File

@@ -22,12 +22,7 @@ final class ExplorerRepository {
} }
func getCreatorProfile(id: Int) -> AnyPublisher<Response, MoyaError> { func getCreatorProfile(id: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(.getCreatorProfile(userId: id))
.getCreatorProfile(
userId: id,
isAdultContentVisible: UserDefaults.isAdultContentVisible()
)
)
} }
func getCreatorDetail(id: Int) -> AnyPublisher<Response, MoyaError> { func getCreatorDetail(id: Int) -> AnyPublisher<Response, MoyaError> {

View File

@@ -25,6 +25,7 @@ enum UserDefaultsKey: String, CaseIterable {
case notShowingEventPopupId case notShowingEventPopupId
case isAdultContentVisible case isAdultContentVisible
case contentPreference case contentPreference
case countryCode
case isAuditionNotification case isAuditionNotification
case searchChannel case searchChannel
case marketingPid case marketingPid
@@ -73,7 +74,7 @@ extension UserDefaults {
static func isAdultContentVisible() -> Bool { static func isAdultContentVisible() -> Bool {
let key = UserDefaultsKey.isAdultContentVisible.rawValue let key = UserDefaultsKey.isAdultContentVisible.rawValue
return UserDefaults.standard.object(forKey: key) != nil ? bool(forKey: .isAdultContentVisible) : true return UserDefaults.standard.object(forKey: key) != nil ? bool(forKey: .isAdultContentVisible) : false
} }
static func reset() { static func reset() {

View File

@@ -9,11 +9,11 @@ import Foundation
import Moya import Moya
enum HomeApi { enum HomeApi {
case getHomeData(isAdultContentVisible: Bool, contentType: ContentType) case getHomeData
case getLatestContentByTheme(theme: String, isAdultContentVisible: Bool, contentType: ContentType) case getLatestContentByTheme(theme: String)
case getDayOfWeekSeriesList(dayOfWeek: SeriesPublishedDaysOfWeek, isAdultContentVisible: Bool, contentType: ContentType) case getDayOfWeekSeriesList(dayOfWeek: SeriesPublishedDaysOfWeek)
case getRecommendContents(isAdultContentVisible: Bool, contentType: ContentType) case getRecommendContents
case getContentRankingBySort(sort: ContentRankingSortType, isAdultContentVisible: Bool, contentType: ContentType) case getContentRankingBySort(sort: ContentRankingSortType)
} }
extension HomeApi: TargetType { extension HomeApi: TargetType {
@@ -46,46 +46,33 @@ extension HomeApi: TargetType {
var task: Moya.Task { var task: Moya.Task {
switch self { switch self {
case .getHomeData(let isAdultContentVisible, let contentType): case .getHomeData:
let parameters = [ let parameters = [
"timezone": TimeZone.current.identifier, "timezone": TimeZone.current.identifier
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String: Any] ] as [String: Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getLatestContentByTheme(let theme, let isAdultContentVisible, let contentType): case .getLatestContentByTheme(let theme):
let parameters = [ let parameters = [
"theme": theme, "theme": theme
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String: Any] ] as [String: Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getDayOfWeekSeriesList(let dayOfWeek, let isAdultContentVisible, let contentType): case .getDayOfWeekSeriesList(let dayOfWeek):
let parameters = [ let parameters = [
"dayOfWeek": dayOfWeek, "dayOfWeek": dayOfWeek
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String: Any] ] as [String: Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getRecommendContents(let isAdultContentVisible, let contentType): case .getRecommendContents:
let parameters = [ return .requestPlain
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String: Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) case .getContentRankingBySort(let sort):
case .getContentRankingBySort(let sort, let isAdultContentVisible, let contentType):
let parameters = [ let parameters = [
"sort": sort, "sort": sort
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String: Any] ] as [String: Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)

View File

@@ -14,50 +14,22 @@ class HomeTabRepository {
private let api = MoyaProvider<HomeApi>() private let api = MoyaProvider<HomeApi>()
func fetchData() -> AnyPublisher<Response, MoyaError> { func fetchData() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(.getHomeData)
.getHomeData(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
} }
func getLatestContentByTheme(theme: String) -> AnyPublisher<Response, MoyaError> { func getLatestContentByTheme(theme: String) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(.getLatestContentByTheme(theme: theme))
.getLatestContentByTheme(
theme: theme,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
} }
func getDayOfWeekSeriesList(dayOfWeek: SeriesPublishedDaysOfWeek) -> AnyPublisher<Response, MoyaError> { func getDayOfWeekSeriesList(dayOfWeek: SeriesPublishedDaysOfWeek) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(.getDayOfWeekSeriesList(dayOfWeek: dayOfWeek))
.getDayOfWeekSeriesList(
dayOfWeek: dayOfWeek,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
} }
func getRecommendContents() -> AnyPublisher<Response, MoyaError> { func getRecommendContents() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(.getRecommendContents)
.getRecommendContents(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
} }
func getContentRankingBySort(sort: ContentRankingSortType) -> AnyPublisher<Response, MoyaError> { func getContentRankingBySort(sort: ContentRankingSortType) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(.getContentRankingBySort(sort: sort))
.getContentRankingBySort(
sort: sort,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
} }
} }

View File

@@ -44,7 +44,14 @@ struct HomeTabView: View {
AppState.shared.setAppStep(step: .login) AppState.shared.setAppStep(step: .login)
return return
} }
if auth == false {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
if isKoreanCountry && auth == false {
pendingAction = { pendingAction = {
AppState.shared AppState.shared
.setAppStep(step: .characterDetail(characterId: characterId)) .setAppStep(step: .characterDetail(characterId: characterId))
@@ -52,6 +59,15 @@ struct HomeTabView: View {
isShowAuthConfirmView = true isShowAuthConfirmView = true
return return
} }
if !UserDefaults.isAdultContentVisible() {
pendingAction = nil
AppState.shared.errorMessage = I18n.Settings.adultContentEnableGuide
AppState.shared.isShowErrorPopup = true
AppState.shared.setAppStep(step: .contentViewSettings)
return
}
AppState.shared.setAppStep(step: .characterDetail(characterId: characterId)) AppState.shared.setAppStep(step: .characterDetail(characterId: characterId))
} }

View File

@@ -408,6 +408,30 @@ enum I18n {
// //
static var alertTitle: String { pick(ko: "알림", en: "Notice", ja: "お知らせ") } static var alertTitle: String { pick(ko: "알림", en: "Notice", ja: "お知らせ") }
static var adultContentAgeCheckTitle: String {
pick(
ko: "당신은 18세 이상입니까?",
en: "Are you over 18 years old?",
ja: "あなたは18歳以上ですか"
)
}
static var adultContentAgeCheckDesc: String {
pick(
ko: "해당 콘텐츠는 18세 이상만 이용이 가능합니다!",
en: "This content is available only to users aged 18 and over!",
ja: "このコンテンツは18歳以上のみ利用可能です"
)
}
static var adultContentEnableGuide: String {
pick(
ko: "민감한 콘텐츠를 보려면 콘텐츠 보기 설정에서 민감한 콘텐츠 보기를 켜주세요.",
en: "To view sensitive content, turn on Sensitive Content in Content View Settings.",
ja: "センシティブなコンテンツを表示するには、コンテンツ表示設定でセンシティブなコンテンツ表示をオンにしてください。"
)
}
// //
static var logoutQuestion: String { static var logoutQuestion: String {
pick( pick(
@@ -692,6 +716,10 @@ enum I18n {
static var joinAllowed: String { pick(ko: "가능", en: "Allowed", ja: "可能") } static var joinAllowed: String { pick(ko: "가능", en: "Allowed", ja: "可能") }
static var joinNotAllowed: String { pick(ko: "불가능", en: "Not allowed", ja: "不可") } static var joinNotAllowed: String { pick(ko: "불가능", en: "Not allowed", ja: "不可") }
static var captureRecordingSetting: String { pick(ko: "캡쳐/녹화 허용", en: "Capture/recording", ja: "キャプチャ/録画") }
static var captureRecordingAllowed: String { pick(ko: "가능", en: "Allowed", ja: "可能") }
static var captureRecordingNotAllowed: String { pick(ko: "불가능", en: "Not allowed", ja: "不可") }
// //
static var allAges: String { pick(ko: "전체 연령", en: "All ages", ja: "全年齢") } static var allAges: String { pick(ko: "전체 연령", en: "All ages", ja: "全年齢") }
static var over19: String { pick(ko: "19세 이상", en: "19+", ja: "R-18") } static var over19: String { pick(ko: "19세 이상", en: "19+", ja: "R-18") }

View File

@@ -41,7 +41,7 @@ enum LiveApi {
case likeHeart(request: LiveRoomLikeHeartRequest) case likeHeart(request: LiveRoomLikeHeartRequest)
case getTotalHeartCount(roomId: Int) case getTotalHeartCount(roomId: Int)
case heartStatus(roomId: Int) case heartStatus(roomId: Int)
case getLiveMain(isAdultContentVisible: Bool, contentType: ContentType) case getLiveMain
} }
extension LiveApi: TargetType { extension LiveApi: TargetType {
@@ -260,11 +260,9 @@ extension LiveApi: TargetType {
case .likeHeart(let request): case .likeHeart(let request):
return .requestJSONEncodable(request) return .requestJSONEncodable(request)
case .getLiveMain(let isAdultContentVisible, let contentType): case .getLiveMain:
let parameters = [ let parameters = [
"timezone": TimeZone.current.identifier, "timezone": TimeZone.current.identifier
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String: Any] ] as [String: Any]
return .requestParameters( return .requestParameters(

View File

@@ -145,11 +145,6 @@ final class LiveRepository {
} }
func getLiveMain() -> AnyPublisher<Response, MoyaError> { func getLiveMain() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(.getLiveMain)
.getLiveMain(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
} }
} }

View File

@@ -210,7 +210,6 @@ final class LiveViewModel: ObservableObject {
timezone: TimeZone.current.identifier, timezone: TimeZone.current.identifier,
dateString: nil, dateString: nil,
status: .NOW, status: .NOW,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
page: page, page: page,
size: pageSize size: pageSize
) )
@@ -268,7 +267,6 @@ final class LiveViewModel: ObservableObject {
timezone: TimeZone.current.identifier, timezone: TimeZone.current.identifier,
dateString: selectedDateString, dateString: selectedDateString,
status: .RESERVATION, status: .RESERVATION,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
page: page, page: page,
size: pageSize size: pageSize
) )

View File

@@ -169,11 +169,29 @@ struct LiveNowAllView: View {
AppState.shared.setAppStep(step: .login) AppState.shared.setAppStep(step: .login)
return return
} }
if isAdult && auth == false {
pendingAction = { openLiveDetail(roomId: roomId) } if isAdult {
isShowAuthConfirmView = true let normalizedCountryCode = UserDefaults
return .string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
if isKoreanCountry && auth == false {
pendingAction = { openLiveDetail(roomId: roomId) }
isShowAuthConfirmView = true
return
}
if !UserDefaults.isAdultContentVisible() {
pendingAction = nil
AppState.shared.errorMessage = I18n.Settings.adultContentEnableGuide
AppState.shared.isShowErrorPopup = true
AppState.shared.setAppStep(step: .contentViewSettings)
return
}
} }
openLiveDetail(roomId: roomId) openLiveDetail(roomId: roomId)
} }

View File

@@ -24,4 +24,5 @@ struct CreateLiveRoomRequest: Encodable {
var menuPan: String = "" var menuPan: String = ""
var isActiveMenuPan: Bool = false var isActiveMenuPan: Bool = false
var isAvailableJoinCreator: Bool = true var isAvailableJoinCreator: Bool = true
var isCaptureRecordingAvailable: Bool = false
} }

View File

@@ -14,4 +14,5 @@ struct GetRecentRoomInfoResponse: Decodable {
let coverImagePath: String let coverImagePath: String
let numberOfPeople: Int let numberOfPeople: Int
let genderRestriction: LiveRoomCreateViewModel.GenderRestriction let genderRestriction: LiveRoomCreateViewModel.GenderRestriction
let isCaptureRecordingAvailable: Bool?
} }

View File

@@ -33,6 +33,14 @@ struct LiveRoomCreateView: View {
} }
var body: some View { var body: some View {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
let isAdultContentVisible = UserDefaults.isAdultContentVisible()
let shouldShowAdultSetting = isAdultContentVisible && (!isKoreanCountry || UserDefaults.bool(forKey: .auth))
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
GeometryReader { proxy in GeometryReader { proxy in
ZStack { ZStack {
@@ -175,7 +183,32 @@ struct LiveRoomCreateView: View {
.frame(width: screenSize().width - 26.7) .frame(width: screenSize().width - 26.7)
.padding(.top, 33.3) .padding(.top, 33.3)
if UserDefaults.bool(forKey: .auth) { VStack(spacing: 13.3) {
Text(I18n.CreateLive.captureRecordingSetting)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(width: screenSize().width - 26.7, alignment: .leading)
HStack(spacing: 13.3) {
SelectedButtonView(
title: I18n.CreateLive.captureRecordingAllowed,
isActive: true,
isSelected: viewModel.isCaptureRecordingAvailable
)
.onTapGesture { viewModel.isCaptureRecordingAvailable = true }
SelectedButtonView(
title: I18n.CreateLive.captureRecordingNotAllowed,
isActive: true,
isSelected: !viewModel.isCaptureRecordingAvailable
)
.onTapGesture { viewModel.isCaptureRecordingAvailable = false }
}
}
.frame(width: screenSize().width - 26.7)
.padding(.top, 33.3)
if shouldShowAdultSetting {
AdultSettingView() AdultSettingView()
.frame(width: screenSize().width - 26.7) .frame(width: screenSize().width - 26.7)
.padding(.top, 33.3) .padding(.top, 33.3)

View File

@@ -100,6 +100,7 @@ final class LiveRoomCreateViewModel: ObservableObject {
@Published var selectedMenu: SelectedMenu? = nil @Published var selectedMenu: SelectedMenu? = nil
@Published var isAvailableJoinCreator = true @Published var isAvailableJoinCreator = true
@Published var isCaptureRecordingAvailable = false
private let repository = LiveRepository() private let repository = LiveRepository()
private var subscription = Set<AnyCancellable>() private var subscription = Set<AnyCancellable>()
@@ -146,6 +147,7 @@ final class LiveRoomCreateViewModel: ObservableObject {
self.coverImagePath = data.coverImagePath self.coverImagePath = data.coverImagePath
self.numberOfPeople = String(data.numberOfPeople) self.numberOfPeople = String(data.numberOfPeople)
self.genderRestriction = data.genderRestriction self.genderRestriction = data.genderRestriction
self.isCaptureRecordingAvailable = data.isCaptureRecordingAvailable ?? false
self.errorMessage = I18n.CreateLive.recentDataLoaded self.errorMessage = I18n.CreateLive.recentDataLoaded
self.isShowPopup = true self.isShowPopup = true
@@ -192,7 +194,8 @@ final class LiveRoomCreateViewModel: ObservableObject {
menuPanId: isActivateMenu ? menuId : 0, menuPanId: isActivateMenu ? menuId : 0,
menuPan: isActivateMenu ? menu : "", menuPan: isActivateMenu ? menu : "",
isActiveMenuPan: isActivateMenu, isActiveMenuPan: isActivateMenu,
isAvailableJoinCreator: isAvailableJoinCreator isAvailableJoinCreator: isAvailableJoinCreator,
isCaptureRecordingAvailable: isCaptureRecordingAvailable
) )
if timeSettingMode == .RESERVATION { if timeSettingMode == .RESERVATION {

View File

@@ -32,8 +32,9 @@ struct GetRoomDetailManager: Decodable {
let introduce: String let introduce: String
let youtubeUrl: String? let youtubeUrl: String?
let instagramUrl: String? let instagramUrl: String?
let websiteUrl: String? let fancimmUrl: String?
let blogUrl: String? let xUrl: String?
let kakaoOpenChatUrl: String?
let profileImageUrl: String let profileImageUrl: String
let isCreator: Bool let isCreator: Bool
} }

View File

@@ -10,7 +10,7 @@ import Kingfisher
struct LiveDetailView: View { struct LiveDetailView: View {
@ObservedObject var viewModel = LiveDetailViewModel() @StateObject private var viewModel = LiveDetailViewModel()
@State private var isExpandParticipantArea = false @State private var isExpandParticipantArea = false
@State private var isShowCancelPopup = false @State private var isShowCancelPopup = false
@@ -152,46 +152,20 @@ struct LiveDetailView: View {
.clipShape(Circle()) .clipShape(Circle())
VStack(spacing: 16.7) { VStack(spacing: 16.7) {
HStack(spacing: 6.7) { HStack(spacing: 8) {
Text(manager.nickname) Text(manager.nickname)
.appFont(size: 16.7, weight: .medium) .appFont(size: 16.7, weight: .medium)
.foregroundColor(Color(hex: "eeeeee")) .foregroundColor(Color(hex: "eeeeee"))
Spacer() Spacer()
if let websiteUrl = manager.websiteUrl, let url = URL(string: websiteUrl), UIApplication.shared.canOpenURL(url) { ForEach(makeSnsItems(from: manager)) { item in
Image("ic_website_blue") Image(item.iconName)
.resizable() .resizable()
.frame(width: 33.3, height: 33.3) .frame(width: 33.3, height: 33.3)
.contentShape(Rectangle())
.onTapGesture { .onTapGesture {
UIApplication.shared.open(url) openSnsLink(item.url)
}
}
if let blogUrl = manager.blogUrl, let url = URL(string: blogUrl), UIApplication.shared.canOpenURL(url) {
Image("ic_blog_blue")
.resizable()
.frame(width: 33.3, height: 33.3)
.onTapGesture {
UIApplication.shared.open(url)
}
}
if let instagramUrl = manager.instagramUrl, let url = URL(string: instagramUrl), UIApplication.shared.canOpenURL(url) {
Image("ic_instagram_blue")
.resizable()
.frame(width: 33.3, height: 33.3)
.onTapGesture {
UIApplication.shared.open(url)
}
}
if let youtubeUrl = manager.youtubeUrl, let url = URL(string: youtubeUrl), UIApplication.shared.canOpenURL(url) {
Image("ic_youtube_play_blue")
.resizable()
.frame(width: 33.3, height: 33.3)
.onTapGesture {
UIApplication.shared.open(url)
} }
} }
} }
@@ -494,4 +468,59 @@ struct LiveDetailView: View {
AppState.shared.back() AppState.shared.back()
} }
} }
private func makeSnsItems(from manager: GetRoomDetailManager) -> [LiveDetailSnsItem] {
var items = [LiveDetailSnsItem]()
appendSnsItem(items: &items, iconName: "ic_sns_youtube", url: manager.youtubeUrl)
appendSnsItem(items: &items, iconName: "ic_sns_instagram", url: manager.instagramUrl)
appendSnsItem(items: &items, iconName: "ic_sns_x", url: manager.xUrl)
appendSnsItem(items: &items, iconName: "ic_sns_fancimm", url: manager.fancimmUrl)
appendSnsItem(items: &items, iconName: "ic_sns_kakao", url: manager.kakaoOpenChatUrl)
return items
}
private func appendSnsItem(items: inout [LiveDetailSnsItem], iconName: String, url: String?) {
guard let url else {
return
}
let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return
}
items.append(LiveDetailSnsItem(iconName: iconName, url: trimmed))
}
private func openSnsLink(_ urlString: String) {
guard let url = normalizedUrl(urlString), UIApplication.shared.canOpenURL(url) else {
return
}
UIApplication.shared.open(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 LiveDetailSnsItem: Identifiable {
let iconName: String
let url: String
var id: String { "\(iconName)-\(url)" }
} }

View File

@@ -29,6 +29,7 @@ struct GetRoomInfoResponse: Decodable {
let creatorLanguageCode: String? let creatorLanguageCode: String?
let isActiveRoulette: Bool let isActiveRoulette: Bool
let isChatFrozen: Bool? let isChatFrozen: Bool?
let isCaptureRecordingAvailable: Bool?
let isPrivateRoom: Bool let isPrivateRoom: Bool
let password: String? let password: String?
} }

View File

@@ -11,7 +11,6 @@ struct GetRoomListRequest {
let timezone: String let timezone: String
let dateString: String? let dateString: String?
let status: LiveRoomStatus let status: LiveRoomStatus
let isAdultContentVisible: Bool
let page: Int let page: Int
let size: Int let size: Int
} }

View File

@@ -64,7 +64,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
@Published var isShowUserProfilePopup = false @Published var isShowUserProfilePopup = false
@Published var changeIsAdult = false { @Published var changeIsAdult = false {
didSet { didSet {
if changeIsAdult && !UserDefaults.bool(forKey: .auth) { if changeIsAdult && requiresAdultAuthenticationByCountry() {
agora.speakerMute(true) agora.speakerMute(true)
} }
} }
@@ -524,6 +524,14 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
role = .LISTENER role = .LISTENER
} }
if isSpeakerMute {
agora.speakerMute(true)
}
if isMute {
agora.mute(true)
}
DEBUG_LOG("agoraConnectSuccess") DEBUG_LOG("agoraConnectSuccess")
if containNoChatRoom() { if containNoChatRoom() {
@@ -645,7 +653,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
getTotalDonationCan() getTotalDonationCan()
getTotalHeartCount() getTotalHeartCount()
if data.isAdult && !UserDefaults.bool(forKey: .auth) { if data.isAdult && requiresAdultAuthenticationByCountry() {
changeIsAdult = true changeIsAdult = true
} }
@@ -673,22 +681,43 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
.store(in: &subscription) .store(in: &subscription)
} }
func toggleMute() { func setMute(_ isMuted: Bool) {
isMute.toggle() isMute = isMuted
agora.mute(isMute) agora.mute(isMuted)
if isMute { let userId = UInt(UserDefaults.int(forKey: .userId))
muteSpeakers.append(UInt(UserDefaults.int(forKey: .userId))) if isMuted {
if !muteSpeakers.contains(userId) {
muteSpeakers.append(userId)
}
} else { } else {
if let index = muteSpeakers.firstIndex(of: UInt(UserDefaults.int(forKey: .userId))) { if let index = muteSpeakers.firstIndex(of: userId) {
muteSpeakers.remove(at: index) muteSpeakers.remove(at: index)
} }
} }
} }
private func requiresAdultAuthenticationByCountry() -> Bool {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
return isKoreanCountry && !UserDefaults.bool(forKey: .auth)
}
func toggleMute() {
setMute(!isMute)
}
func setSpeakerMute(_ isMuted: Bool) {
isSpeakerMute = isMuted
agora.speakerMute(isMuted)
}
func toggleSpeakerMute() { func toggleSpeakerMute() {
isSpeakerMute.toggle() setSpeakerMute(!isSpeakerMute)
agora.speakerMute(isSpeakerMute)
} }
func sendMessage(chatMessage: String, onSuccess: @escaping () -> Void) { func sendMessage(chatMessage: String, onSuccess: @escaping () -> Void) {
@@ -1026,9 +1055,12 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
agora.sendMessageToPeer(peerId: String(peerId), rawMessage: LiveRoomRequestType.CHANGE_LISTENER.rawValue.data(using: .utf8)!) { [unowned self] _, error in agora.sendMessageToPeer(peerId: String(peerId), rawMessage: LiveRoomRequestType.CHANGE_LISTENER.rawValue.data(using: .utf8)!) { [unowned self] _, error in
if error == nil { if error == nil {
if isFromManager { if isFromManager {
getRoomInfo()
setManagerMessage()
releaseManagerMessageToPeer(userId: peerId) releaseManagerMessageToPeer(userId: peerId)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
self?.getRoomInfo()
}
self.popupContent = "\(getUserNicknameAndProfileUrl(accountId: peerId).nickname)님을 스탭에서 해제했어요." self.popupContent = "\(getUserNicknameAndProfileUrl(accountId: peerId).nickname)님을 스탭에서 해제했어요."
} else { } else {
self.popupContent = "\(getUserNicknameAndProfileUrl(accountId: peerId).nickname)님을 리스너로 변경했어요." self.popupContent = "\(getUserNicknameAndProfileUrl(accountId: peerId).nickname)님을 리스너로 변경했어요."
@@ -1072,7 +1104,10 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
} }
func setListener() { func setListener() {
repository.setListener(roomId: AppState.shared.roomId, userId: UserDefaults.int(forKey: .userId)) let currentUserId = UserDefaults.int(forKey: .userId)
let wasManager = liveRoomInfo?.managerList.contains(where: { $0.id == currentUserId }) ?? false
repository.setListener(roomId: AppState.shared.roomId, userId: currentUserId)
.sink { result in .sink { result in
switch result { switch result {
case .finished: case .finished:
@@ -1092,10 +1127,14 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
self.agora.setRole(role: .audience) self.agora.setRole(role: .audience)
self.isMute = false self.isMute = false
self.agora.mute(isMute) self.agora.mute(isMute)
if let index = self.muteSpeakers.firstIndex(of: UInt(UserDefaults.int(forKey: .userId))) { if let index = self.muteSpeakers.firstIndex(of: UInt(currentUserId)) {
self.muteSpeakers.remove(at: index) self.muteSpeakers.remove(at: index)
} }
self.getRoomInfo() self.getRoomInfo()
if wasManager {
self.setManagerMessage()
}
} }
} catch { } catch {
} }

View File

@@ -32,6 +32,10 @@ struct LiveRoomViewV2: View {
@State private var guestFollowButtonTypeOverride: FollowButtonImageType? = nil @State private var guestFollowButtonTypeOverride: FollowButtonImageType? = nil
@State private var selectedChatForDelete: LiveRoomNormalChat? = nil @State private var selectedChatForDelete: LiveRoomNormalChat? = nil
@State private var isShowChatDeleteDialog: Bool = false @State private var isShowChatDeleteDialog: Bool = false
// /
@State private var isScreenCaptureProtected: Bool = UIScreen.main.isCaptured
@State private var shouldRestoreSpeakerMuteAfterCapture: Bool = false
@State private var shouldRestoreMicMuteAfterCapture: Bool = false
let heartWaveTimer = Timer.publish(every: 1/60, on: .main, in: .common).autoconnect() let heartWaveTimer = Timer.publish(every: 1/60, on: .main, in: .common).autoconnect()
private var appliedKeyboardHeight: CGFloat { private var appliedKeyboardHeight: CGFloat {
@@ -58,8 +62,38 @@ struct LiveRoomViewV2: View {
return I18n.LiveRoom.chatFreezeBlockedMessage return I18n.LiveRoom.chatFreezeBlockedMessage
} }
private var isCurrentUserHost: Bool {
guard let creatorId = viewModel.liveRoomInfo?.creatorId else {
return false
}
return creatorId == UserDefaults.int(forKey: .userId)
}
private var isCurrentUserStaff: Bool {
guard let managerList = viewModel.liveRoomInfo?.managerList else {
return false
}
let currentUserId = UserDefaults.int(forKey: .userId)
return managerList.contains { $0.id == currentUserId }
}
private var shouldEnforceScreenCaptureProtection: Bool {
guard let liveRoomInfo = viewModel.liveRoomInfo else {
return true
}
if liveRoomInfo.isCaptureRecordingAvailable == true {
return false
}
return !(isCurrentUserHost || isCurrentUserStaff)
}
var body: some View { var body: some View {
ZStack { ScreenCaptureSecureContainer(isSecureModeEnabled: shouldEnforceScreenCaptureProtection) {
ZStack {
Color.black.edgesIgnoringSafeArea(.all) Color.black.edgesIgnoringSafeArea(.all)
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -527,9 +561,10 @@ struct LiveRoomViewV2: View {
.onAppear { .onAppear {
UIApplication.shared.isIdleTimerDisabled = true UIApplication.shared.isIdleTimerDisabled = true
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
viewModel.initAgoraEngine()
syncScreenCaptureProtectionState()
viewModel.getMemberCan() viewModel.getMemberCan()
viewModel.initAgoraEngine()
viewModel.getRoomInfo() viewModel.getRoomInfo()
viewModel.getBlockedMemberIdList() viewModel.getBlockedMemberIdList()
@@ -544,6 +579,8 @@ struct LiveRoomViewV2: View {
.onDisappear { .onDisappear {
UIApplication.shared.isIdleTimerDisabled = false UIApplication.shared.isIdleTimerDisabled = false
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
//
releaseForcedCaptureMute()
viewModel.stopV2VTranslationIfJoined() viewModel.stopV2VTranslationIfJoined()
viewModel.stopPeriodicPlaybackValidation() viewModel.stopPeriodicPlaybackValidation()
} }
@@ -845,46 +882,54 @@ struct LiveRoomViewV2: View {
if isImageLoading { if isImageLoading {
LoadingView() LoadingView()
} }
if isScreenCaptureProtected {
Color.black
.ignoresSafeArea()
.allowsHitTesting(true)
}
} }
.overlay(alignment: .center) { .overlay(alignment: .center) {
ZStack { if !isScreenCaptureProtected {
// ( )
WaterHeartView(progress: waterProgress, show: showWaterHeart, phase: wavePhase)
.frame(width: 210, height: 210)
.allowsHitTesting(false)
.opacity(showWaterHeart ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: showWaterHeart)
// ( ) - 01 (1.0~1.5s )
WaterHeartView(progress: viewModel.remoteWaterProgress,
show: viewModel.isShowRemoteBigHeart,
phase: viewModel.remoteWavePhase)
.frame(width: 210, height: 210)
.scaleEffect(viewModel.remoteHeartScale)
.allowsHitTesting(false)
//
.opacity((viewModel.isShowRemoteBigHeart && !showWaterHeart) ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: viewModel.isShowRemoteBigHeart)
// ( , #ff959a)
ZStack { ZStack {
ForEach(viewModel.bigHeartParticles) { p in // ( )
HeartShape() WaterHeartView(progress: waterProgress, show: showWaterHeart, phase: wavePhase)
.fill(Color(hex: "ff959a")) .frame(width: 210, height: 210)
.frame(width: p.size * p.scale, height: p.size * p.scale) .allowsHitTesting(false)
.rotationEffect(.degrees(p.rotation)) .opacity(showWaterHeart ? 1 : 0)
.offset(x: p.x, y: p.y) .animation(.easeInOut(duration: 0.2), value: showWaterHeart)
.opacity(p.opacity)
.allowsHitTesting(false) // ( ) - 01 (1.0~1.5s )
WaterHeartView(progress: viewModel.remoteWaterProgress,
show: viewModel.isShowRemoteBigHeart,
phase: viewModel.remoteWavePhase)
.frame(width: 210, height: 210)
.scaleEffect(viewModel.remoteHeartScale)
.allowsHitTesting(false)
//
.opacity((viewModel.isShowRemoteBigHeart && !showWaterHeart) ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: viewModel.isShowRemoteBigHeart)
// ( , #ff959a)
ZStack {
ForEach(viewModel.bigHeartParticles) { p in
HeartShape()
.fill(Color(hex: "ff959a"))
.frame(width: p.size * p.scale, height: p.size * p.scale)
.rotationEffect(.degrees(p.rotation))
.offset(x: p.x, y: p.y)
.opacity(p.opacity)
.allowsHitTesting(false)
}
} }
// drawingGroup (Rect) ,
.frame(maxWidth: .infinity, maxHeight: .infinity)
.drawingGroup(opaque: false, colorMode: .linear)
} }
// drawingGroup (Rect) , //
.frame(maxWidth: .infinity, maxHeight: .infinity) .offset(y: appliedKeyboardHeight > 0 ? -(appliedKeyboardHeight / 2 + 60) : 0)
.drawingGroup(opaque: false, colorMode: .linear) .animation(.spring(response: 0.3, dampingFraction: 0.85), value: appliedKeyboardHeight)
} }
//
.offset(y: appliedKeyboardHeight > 0 ? -(appliedKeyboardHeight / 2 + 60) : 0)
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: appliedKeyboardHeight)
} }
.onReceive(heartWaveTimer) { _ in .onReceive(heartWaveTimer) { _ in
guard isLongPressingHeart else { return } guard isLongPressingHeart else { return }
@@ -909,11 +954,35 @@ struct LiveRoomViewV2: View {
.onReceive(NotificationCenter.default.publisher(for: .requestLiveRoomQuitForExternalNavigation)) { _ in .onReceive(NotificationCenter.default.publisher(for: .requestLiveRoomQuitForExternalNavigation)) { _ in
viewModel.quitRoom() viewModel.quitRoom()
} }
// ( / )
.onReceive(NotificationCenter.default.publisher(for: UIScreen.capturedDidChangeNotification)) { _ in
syncScreenCaptureProtectionState()
}
.onChange(of: viewModel.liveRoomInfo?.creatorId) { _ in
syncScreenCaptureProtectionState()
}
.onChange(of: viewModel.liveRoomInfo?.managerList) { _ in
syncScreenCaptureProtectionState()
}
.onChange(of: viewModel.liveRoomInfo?.isCaptureRecordingAvailable) { _ in
syncScreenCaptureProtectionState()
}
.onChange(of: viewModel.isChatFrozenForCurrentUser) { isFrozen in .onChange(of: viewModel.isChatFrozenForCurrentUser) { isFrozen in
if isFrozen { if isFrozen {
hideKeyboard() hideKeyboard()
} }
} }
.onChange(of: viewModel.role) { role in
guard isScreenCaptureProtected,
role == .SPEAKER,
!viewModel.isMute else {
return
}
//
viewModel.setMute(true)
shouldRestoreMicMuteAfterCapture = true
}
.ignoresSafeArea(.keyboard) .ignoresSafeArea(.keyboard)
.edgesIgnoringSafeArea(appliedKeyboardHeight > 0 ? .bottom : .init()) .edgesIgnoringSafeArea(appliedKeyboardHeight > 0 ? .bottom : .init())
.sheet( .sheet(
@@ -1022,6 +1091,7 @@ struct LiveRoomViewV2: View {
hideKeyboard() hideKeyboard()
} }
} }
}
} }
private func estimatedHeight(for text: String, width: CGFloat) -> CGFloat { private func estimatedHeight(for text: String, width: CGFloat) -> CGFloat {
@@ -1072,7 +1142,263 @@ struct LiveRoomViewV2: View {
} }
} }
// / SwiftUI
private struct ScreenCaptureSecureContainer<Content: View>: UIViewControllerRepresentable {
let isSecureModeEnabled: Bool
let content: Content
init(isSecureModeEnabled: Bool = true, @ViewBuilder content: () -> Content) {
self.isSecureModeEnabled = isSecureModeEnabled
self.content = content()
}
func makeUIViewController(context: Context) -> ScreenCaptureSecureHostingController<Content> {
ScreenCaptureSecureHostingController(
rootView: content,
isSecureModeEnabled: isSecureModeEnabled
)
}
func updateUIViewController(_ uiViewController: ScreenCaptureSecureHostingController<Content>, context: Context) {
uiViewController.update(
rootView: content,
isSecureModeEnabled: isSecureModeEnabled
)
}
}
// SwiftUI UIKit
private final class ScreenCaptureSecureHostingController<Content: View>: UIViewController {
private let secureContainerView = ScreenCaptureSecureView()
private let hostingController: UIHostingController<Content>
private var isSecureModeEnabled: Bool
init(rootView: Content, isSecureModeEnabled: Bool) {
hostingController = UIHostingController(rootView: rootView)
self.isSecureModeEnabled = isSecureModeEnabled
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
view = secureContainerView
}
override func viewDidLoad() {
super.viewDidLoad()
hostingController.view.backgroundColor = .clear
addChild(hostingController)
secureContainerView.setSecureModeEnabled(isSecureModeEnabled)
secureContainerView.embed(contentView: hostingController.view)
hostingController.didMove(toParent: self)
}
func update(rootView: Content, isSecureModeEnabled: Bool) {
self.isSecureModeEnabled = isSecureModeEnabled
hostingController.rootView = rootView
secureContainerView.setSecureModeEnabled(isSecureModeEnabled)
secureContainerView.embed(contentView: hostingController.view)
}
}
// isSecureTextEntry
private final class ScreenCaptureSecureView: UIView {
private let secureTextField = ScreenCaptureSecureTextField()
private weak var secureContentView: UIView?
private let failClosedOverlayView = UIView()
private var didLogFailClosedActivation = false
private var isSecureModeEnabled = true
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setup() {
backgroundColor = .clear
//
secureTextField.translatesAutoresizingMaskIntoConstraints = false
secureTextField.isSecureTextEntry = true
secureTextField.backgroundColor = .clear
secureTextField.textColor = .clear
secureTextField.tintColor = .clear
addSubview(secureTextField)
NSLayoutConstraint.activate([
secureTextField.topAnchor.constraint(equalTo: topAnchor),
secureTextField.leadingAnchor.constraint(equalTo: leadingAnchor),
secureTextField.trailingAnchor.constraint(equalTo: trailingAnchor),
secureTextField.bottomAnchor.constraint(equalTo: bottomAnchor)
])
failClosedOverlayView.translatesAutoresizingMaskIntoConstraints = false
failClosedOverlayView.backgroundColor = .black
failClosedOverlayView.isUserInteractionEnabled = true
failClosedOverlayView.isHidden = true
addSubview(failClosedOverlayView)
NSLayoutConstraint.activate([
failClosedOverlayView.topAnchor.constraint(equalTo: topAnchor),
failClosedOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
failClosedOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
failClosedOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
//
secureContentView = resolveSecureContentView()
updateFailClosedState(isActive: secureContentView == nil)
}
func embed(contentView: UIView) {
if !isSecureModeEnabled {
updateFailClosedState(isActive: false)
guard contentView.superview !== self else {
return
}
contentView.removeFromSuperview()
contentView.translatesAutoresizingMaskIntoConstraints = false
insertSubview(contentView, belowSubview: failClosedOverlayView)
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: topAnchor),
contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
return
}
if secureContentView == nil {
secureContentView = resolveSecureContentView()
}
guard let secureContentView else {
contentView.removeFromSuperview()
updateFailClosedState(isActive: true)
return
}
updateFailClosedState(isActive: false)
guard contentView.superview !== secureContentView else {
return
}
//
contentView.removeFromSuperview()
contentView.translatesAutoresizingMaskIntoConstraints = false
secureContentView.addSubview(contentView)
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: secureContentView.topAnchor),
contentView.leadingAnchor.constraint(equalTo: secureContentView.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: secureContentView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: secureContentView.bottomAnchor)
])
}
func setSecureModeEnabled(_ isEnabled: Bool) {
isSecureModeEnabled = isEnabled
secureTextField.isHidden = !isEnabled
if isEnabled {
if secureContentView == nil {
secureContentView = resolveSecureContentView()
}
updateFailClosedState(isActive: secureContentView == nil)
return
}
updateFailClosedState(isActive: false)
}
private func resolveSecureContentView() -> UIView? {
secureTextField.subviews.first {
let className = NSStringFromClass(type(of: $0))
return className.contains("CanvasView")
}
}
private func updateFailClosedState(isActive: Bool) {
failClosedOverlayView.isHidden = !isActive
guard isActive, !didLogFailClosedActivation else {
return
}
didLogFailClosedActivation = true
ERROR_LOG("[ScreenCaptureSecureView] secure canvas lookup failed. Activating fail-closed overlay.")
}
}
// UI
private final class ScreenCaptureSecureTextField: UITextField {
override var canBecomeFirstResponder: Bool {
false
}
override func becomeFirstResponder() -> Bool {
false
}
}
private extension LiveRoomViewV2 { private extension LiveRoomViewV2 {
func syncScreenCaptureProtectionState() {
guard shouldEnforceScreenCaptureProtection else {
isScreenCaptureProtected = false
releaseForcedCaptureMute()
return
}
applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)
}
func applyScreenCaptureProtection(isCaptured: Bool) {
// UI
isScreenCaptureProtected = isCaptured
if isCaptured {
//
if !viewModel.isSpeakerMute {
viewModel.setSpeakerMute(true)
shouldRestoreSpeakerMuteAfterCapture = true
}
if !viewModel.isMute {
viewModel.setMute(true)
shouldRestoreMicMuteAfterCapture = true
}
return
}
releaseForcedCaptureMute()
}
func releaseForcedCaptureMute() {
//
if shouldRestoreSpeakerMuteAfterCapture {
if viewModel.isSpeakerMute {
viewModel.setSpeakerMute(false)
}
shouldRestoreSpeakerMuteAfterCapture = false
}
if shouldRestoreMicMuteAfterCapture {
if viewModel.isMute {
viewModel.setMute(false)
}
shouldRestoreMicMuteAfterCapture = false
}
}
func guestFollowButtonType(liveRoomInfo: GetRoomInfoResponse) -> FollowButtonImageType { func guestFollowButtonType(liveRoomInfo: GetRoomInfoResponse) -> FollowButtonImageType {
if liveRoomInfo.isFollowing { if liveRoomInfo.isFollowing {
return guestFollowButtonTypeOverride ?? .following return guestFollowButtonTypeOverride ?? .following

View File

@@ -472,11 +472,27 @@ struct HomeView: View {
AppState.shared.setAppStep(step: .login) AppState.shared.setAppStep(step: .login)
return return
} }
if isAdult && auth == false {
pendingAction = { openLiveDetail(roomId: roomId) } if isAdult {
isShowAuthConfirmView = true let normalizedCountryCode = UserDefaults
return .string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
if isKoreanCountry && auth == false {
pendingAction = { openLiveDetail(roomId: roomId) }
isShowAuthConfirmView = true
return
}
if !UserDefaults.isAdultContentVisible() {
pendingAction = nil
moveToContentSettingsWithGuideToast()
return
}
} }
openLiveDetail(roomId: roomId) openLiveDetail(roomId: roomId)
} }
@@ -495,6 +511,11 @@ struct HomeView: View {
) )
} }
private func moveToContentSettingsWithGuideToast() {
AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide)
AppState.shared.setAppStep(step: .contentViewSettings)
}
private func handleExternalNavigationRequest( private func handleExternalNavigationRequest(
value: Int, value: Int,
navigationAction: @escaping () -> Void, navigationAction: @escaping () -> Void,

View File

@@ -59,6 +59,9 @@ final class HomeViewModel: ObservableObject {
UserDefaults.set(data.isAuth, forKey: .auth) UserDefaults.set(data.isAuth, forKey: .auth)
UserDefaults.set(data.role.rawValue, forKey: .role) UserDefaults.set(data.role.rawValue, forKey: .role)
UserDefaults.set(data.auditionNotice ?? false, forKey: .isAuditionNotification) UserDefaults.set(data.auditionNotice ?? false, forKey: .isAuditionNotification)
UserDefaults.set(data.countryCode, forKey: .countryCode)
UserDefaults.set(data.isAdultContentVisible, forKey: .isAdultContentVisible)
UserDefaults.set(data.contentType, forKey: .contentPreference)
if data.followingChannelLiveNotice == nil && data.followingChannelUploadContentNotice == nil && data.messageNotice == nil { if data.followingChannelLiveNotice == nil && data.followingChannelUploadContentNotice == nil && data.messageNotice == nil {
AppState.shared.isShowNotificationSettingsDialog = true AppState.shared.isShowNotificationSettingsDialog = true
} }

View File

@@ -21,6 +21,12 @@ struct MyPageView: View {
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token) @AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
var body: some View { var body: some View {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
viewModel.isShowAuthView { viewModel.isShowAuthView {
@@ -114,6 +120,7 @@ struct MyPageView: View {
CategoryButtonsView( CategoryButtonsView(
isShowAuthView: $viewModel.isShowAuthView, isShowAuthView: $viewModel.isShowAuthView,
isAuthenticated: viewModel.isAuth, isAuthenticated: viewModel.isAuth,
isKoreanCountry: isKoreanCountry,
showMessage: { showMessage: {
viewModel.errorMessage = $0 viewModel.errorMessage = $0
viewModel.isShowPopup = true viewModel.isShowPopup = true
@@ -381,6 +388,7 @@ struct CategoryButtonsView: View {
@Binding var isShowAuthView: Bool @Binding var isShowAuthView: Bool
let isAuthenticated: Bool let isAuthenticated: Bool
let isKoreanCountry: Bool
let showMessage: (String) -> Void let showMessage: (String) -> Void
let refresh: () -> Void let refresh: () -> Void
@@ -420,12 +428,14 @@ struct CategoryButtonsView: View {
AppState.shared.setAppStep(step: .serviceCenter) AppState.shared.setAppStep(step: .serviceCenter)
} }
CategoryButtonItem( if isKoreanCountry {
icon: "ic_my_auth", CategoryButtonItem(
title: isAuthenticated ? "인증완료" : "본인인증" icon: "ic_my_auth",
) { title: isAuthenticated ? "인증완료" : "본인인증"
if !isAuthenticated { ) {
isShowAuthView = true if !isAuthenticated {
isShowAuthView = true
}
} }
} }
} }

View File

@@ -9,10 +9,10 @@ import Foundation
import Moya import Moya
enum SearchApi { enum SearchApi {
case searchUnified(keyword: String, isAdultContentVisible: Bool, contentType: ContentType) case searchUnified(keyword: String)
case searchCreatorList(keyword: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) case searchCreatorList(keyword: String, page: Int, size: Int)
case searchContentList(keyword: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) case searchContentList(keyword: String, page: Int, size: Int)
case searchSeriesList(keyword: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) case searchSeriesList(keyword: String, page: Int, size: Int)
} }
extension SearchApi: TargetType { extension SearchApi: TargetType {
@@ -42,42 +42,34 @@ extension SearchApi: TargetType {
var task: Moya.Task { var task: Moya.Task {
switch self { switch self {
case .searchUnified(let keyword, let isAdultContentVisible, let contentType): case .searchUnified(let keyword):
let parameters = [ let parameters = [
"keyword": keyword, "keyword": keyword
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .searchCreatorList(let keyword, let isAdultContentVisible, let contentType, let page, let size): case .searchCreatorList(let keyword, let page, let size):
let parameters = [ let parameters = [
"keyword": keyword, "keyword": keyword,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1, "page": page - 1,
"size": size "size": size
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .searchContentList(let keyword, let isAdultContentVisible, let contentType, let page, let size): case .searchContentList(let keyword, let page, let size):
let parameters = [ let parameters = [
"keyword": keyword, "keyword": keyword,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1, "page": page - 1,
"size": size "size": size
] as [String : Any] ] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .searchSeriesList(let keyword, let isAdultContentVisible, let contentType, let page, let size): case .searchSeriesList(let keyword, let page, let size):
let parameters = [ let parameters = [
"keyword": keyword, "keyword": keyword,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1, "page": page - 1,
"size": size "size": size
] as [String : Any] ] as [String : Any]

View File

@@ -14,21 +14,13 @@ final class SearchRepository {
private let api = MoyaProvider<SearchApi>() private let api = MoyaProvider<SearchApi>()
func searchUnified(keyword: String) -> AnyPublisher<Response, MoyaError> { func searchUnified(keyword: String) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(.searchUnified(keyword: keyword))
.searchUnified(
keyword: keyword,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
} }
func searchCreatorList(keyword: String, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { func searchCreatorList(keyword: String, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher( return api.requestPublisher(
.searchCreatorList( .searchCreatorList(
keyword: keyword, keyword: keyword,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page, page: page,
size: size size: size
) )
@@ -39,8 +31,6 @@ final class SearchRepository {
return api.requestPublisher( return api.requestPublisher(
.searchContentList( .searchContentList(
keyword: keyword, keyword: keyword,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page, page: page,
size: size size: size
) )
@@ -51,8 +41,6 @@ final class SearchRepository {
return api.requestPublisher( return api.requestPublisher(
.searchSeriesList( .searchSeriesList(
keyword: keyword, keyword: keyword,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page, page: page,
size: size size: size
) )

View File

@@ -10,113 +10,138 @@ import SwiftUI
struct ContentSettingsView: View { struct ContentSettingsView: View {
@StateObject var viewModel = ContentSettingsViewModel() @StateObject var viewModel = ContentSettingsViewModel()
@ObservedObject private var appState = AppState.shared
var body: some View { var body: some View {
ZStack { BaseView(isLoading: $viewModel.isLoading) {
Color.black.ignoresSafeArea() ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
DetailNavigationBar(title: "콘텐츠 보기 설정") { DetailNavigationBar(title: "콘텐츠 보기 설정") {
if AppState.shared.isRestartApp { if AppState.shared.isRestartApp {
AppState.shared.setAppStep(step: .splash) AppState.shared.setAppStep(step: .splash)
} else { } else {
AppState.shared.back() AppState.shared.back()
}
}
ScrollView(.vertical) {
VStack(spacing: 0) {
HStack(spacing: 0) {
Text("민감한 콘텐츠 보기")
.appFont(size: 15, weight: .bold)
.foregroundColor(Color(hex: "eeeeee"))
Spacer()
Image(viewModel.isAdultContentVisible ? "btn_toggle_on_big" : "btn_toggle_off_big")
.resizable()
.frame(width: 44, height: 27)
.onTapGesture {
viewModel.isAdultContentVisible.toggle()
}
} }
.frame(height: 50) }
if viewModel.isAdultContentVisible {
Rectangle()
.frame(height: 1)
.foregroundColor(Color.gray90.opacity(0.3))
ScrollView(.vertical) {
VStack(spacing: 0) {
HStack(spacing: 0) { HStack(spacing: 0) {
HStack(spacing: 13.3) { Text("민감한 콘텐츠 보기")
Image( .appFont(size: 15, weight: .bold)
viewModel.adultContentPreference == .ALL ? .foregroundColor(Color(hex: "eeeeee"))
"btn_radio_select_selected" :
"btn_radio_select_normal"
)
.resizable()
.frame(width: 20, height: 20)
Text("전체")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
}
.contentShape(Rectangle())
.onTapGesture {
viewModel.adultContentPreference = .ALL
}
Spacer() Spacer()
HStack(spacing: 13.3) { Image(viewModel.isAdultContentVisible ? "btn_toggle_on_big" : "btn_toggle_off_big")
Image(
viewModel.adultContentPreference == .MALE ?
"btn_radio_select_selected" :
"btn_radio_select_normal"
)
.resizable() .resizable()
.frame(width: 20, height: 20) .frame(width: 44, height: 27)
.onTapGesture {
Text("남성향") viewModel.handleAdultContentToggleTap()
.appFont(size: 13.3, weight: .medium) }
.foregroundColor(Color.grayee)
}
.contentShape(Rectangle())
.onTapGesture {
viewModel.adultContentPreference = .MALE
}
Spacer()
HStack(spacing: 13.3) {
Image(
viewModel.adultContentPreference == .FEMALE ?
"btn_radio_select_selected" :
"btn_radio_select_normal"
)
.resizable()
.frame(width: 20, height: 20)
Text("여성향")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
}
.contentShape(Rectangle())
.onTapGesture {
viewModel.adultContentPreference = .FEMALE
}
Spacer()
} }
.frame(height: 50) .frame(height: 50)
if viewModel.isAdultContentVisible {
Rectangle()
.frame(height: 1)
.foregroundColor(Color.gray90.opacity(0.3))
HStack(spacing: 0) {
HStack(spacing: 13.3) {
Image(
viewModel.adultContentPreference == .ALL ?
"btn_radio_select_selected" :
"btn_radio_select_normal"
)
.resizable()
.frame(width: 20, height: 20)
Text("전체")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
}
.contentShape(Rectangle())
.onTapGesture {
viewModel.adultContentPreference = .ALL
}
Spacer()
HStack(spacing: 13.3) {
Image(
viewModel.adultContentPreference == .MALE ?
"btn_radio_select_selected" :
"btn_radio_select_normal"
)
.resizable()
.frame(width: 20, height: 20)
Text("남성향")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
}
.contentShape(Rectangle())
.onTapGesture {
viewModel.adultContentPreference = .MALE
}
Spacer()
HStack(spacing: 13.3) {
Image(
viewModel.adultContentPreference == .FEMALE ?
"btn_radio_select_selected" :
"btn_radio_select_normal"
)
.resizable()
.frame(width: 20, height: 20)
Text("여성향")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
}
.contentShape(Rectangle())
.onTapGesture {
viewModel.adultContentPreference = .FEMALE
}
Spacer()
}
.frame(height: 50)
}
} }
.padding(.vertical, 6.7)
.padding(.horizontal, 13.3)
.background(Color.gray22)
.cornerRadius(10)
.padding(.top, 13.3)
.padding(.horizontal, 13.3)
} }
.padding(.vertical, 6.7)
.padding(.horizontal, 13.3)
.background(Color.gray22)
.cornerRadius(10)
.padding(.top, 13.3)
.padding(.horizontal, 13.3)
} }
if viewModel.isShowAdultContentAgeCheckDialog {
SodaDialog(
title: I18n.Settings.adultContentAgeCheckTitle,
desc: I18n.Settings.adultContentAgeCheckDesc,
confirmButtonTitle: I18n.Common.yes,
confirmButtonAction: {
viewModel.confirmAdultContentAgeCheck()
},
cancelButtonTitle: I18n.Common.no,
cancelButtonAction: {
viewModel.cancelAdultContentAgeCheck()
}
)
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear {
if let pendingGuideMessage = appState.consumePendingContentSettingsGuideMessage() {
viewModel.errorMessage = pendingGuideMessage
viewModel.isShowPopup = true
} }
} }
} }

View File

@@ -6,28 +6,183 @@
// //
import Foundation import Foundation
import Combine
final class ContentSettingsViewModel: ObservableObject { final class ContentSettingsViewModel: ObservableObject {
@Published var isAdultContentVisible = UserDefaults.isAdultContentVisible() { private let userRepository = UserRepository()
didSet { private var subscription = Set<AnyCancellable>()
if oldValue != isAdultContentVisible { private let contentPreferenceSubject = PassthroughSubject<ContentPreferenceState, Never>()
UserDefaults.set(isAdultContentVisible, forKey: .isAdultContentVisible) private var latestRequestToken = UUID()
AppState.shared.isRestartApp = true private var isApplyingServerState = false
private var lastSyncedState: ContentPreferenceState
if !isAdultContentVisible { @Published var isLoading = false
adultContentPreference = .ALL @Published var errorMessage = ""
UserDefaults.set(ContentType.ALL.rawValue, forKey: .contentPreference) @Published var isShowPopup = false
@Published var isShowAdultContentAgeCheckDialog = false
@Published var isAdultContentVisible: Bool
@Published var adultContentPreference: ContentType
init() {
let isAdultContentVisible = UserDefaults.isAdultContentVisible()
let contentPreference = ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? .ALL
let initialState = ContentPreferenceState(
isAdultContentVisible: isAdultContentVisible,
contentType: isAdultContentVisible ? contentPreference : .ALL
)
_isAdultContentVisible = Published(initialValue: isAdultContentVisible)
_adultContentPreference = Published(initialValue: isAdultContentVisible ? contentPreference : .ALL)
lastSyncedState = initialState
bindContentPreference()
}
private func bindContentPreference() {
$isAdultContentVisible
.dropFirst()
.removeDuplicates()
.sink { [weak self] isAdultContentVisible in
guard let self = self else { return }
if self.isApplyingServerState { return }
if !isAdultContentVisible && self.adultContentPreference != .ALL {
self.adultContentPreference = .ALL
} }
} }
.store(in: &subscription)
Publishers.CombineLatest($isAdultContentVisible, $adultContentPreference)
.map { isAdultContentVisible, adultContentPreference in
ContentPreferenceState(
isAdultContentVisible: isAdultContentVisible,
contentType: isAdultContentVisible ? adultContentPreference : .ALL
)
}
.removeDuplicates()
.dropFirst()
.sink { [weak self] state in
guard let self = self else { return }
if self.isApplyingServerState { return }
self.applyLocalState(state)
self.contentPreferenceSubject.send(state)
}
.store(in: &subscription)
contentPreferenceSubject
.removeDuplicates()
.debounce(for: .seconds(0.3), scheduler: RunLoop.main)
.sink { [weak self] state in
self?.updateContentPreference(state: state)
}
.store(in: &subscription)
}
private func updateContentPreference(state: ContentPreferenceState) {
let request = makeUpdateContentPreferenceRequest(from: lastSyncedState, to: state)
if request.isEmpty {
return
}
let requestToken = UUID()
latestRequestToken = requestToken
isLoading = true
userRepository
.updateContentPreference(
request: request
)
.sink { [weak self] result in
guard let self = self else { return }
guard self.latestRequestToken == requestToken else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
self.isLoading = false
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
guard self.latestRequestToken == requestToken else { return }
do {
let decoded = try JSONDecoder().decode(ApiResponse<UpdateContentPreferenceResponse>.self, from: response.data)
if let data = decoded.data, decoded.success {
let serverState = ContentPreferenceState(
isAdultContentVisible: data.isAdultContentVisible,
contentType: data.isAdultContentVisible ? data.contentType : .ALL
)
self.applyServerState(serverState)
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
self.isLoading = false
}
.store(in: &subscription)
}
private func applyLocalState(_ state: ContentPreferenceState) {
UserDefaults.set(state.isAdultContentVisible, forKey: .isAdultContentVisible)
UserDefaults.set(state.contentType.rawValue, forKey: .contentPreference)
AppState.shared.isRestartApp = true
}
private func applyServerState(_ state: ContentPreferenceState) {
isApplyingServerState = true
isAdultContentVisible = state.isAdultContentVisible
adultContentPreference = state.contentType
applyLocalState(state)
lastSyncedState = state
isApplyingServerState = false
}
private func makeUpdateContentPreferenceRequest(from previousState: ContentPreferenceState, to currentState: ContentPreferenceState) -> UpdateContentPreferenceRequest {
let isAdultContentVisible = previousState.isAdultContentVisible != currentState.isAdultContentVisible
? currentState.isAdultContentVisible
: nil
let contentType = previousState.contentType != currentState.contentType
? currentState.contentType
: nil
return UpdateContentPreferenceRequest(
isAdultContentVisible: isAdultContentVisible,
contentType: contentType
)
}
func handleAdultContentToggleTap() {
if isAdultContentVisible {
isAdultContentVisible = false
} else {
isShowAdultContentAgeCheckDialog = true
} }
} }
@Published var adultContentPreference = ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL { func confirmAdultContentAgeCheck() {
didSet { isShowAdultContentAgeCheckDialog = false
if oldValue != adultContentPreference { isAdultContentVisible = true
UserDefaults.set(adultContentPreference.rawValue, forKey: .contentPreference) }
AppState.shared.isRestartApp = true
} func cancelAdultContentAgeCheck() {
} isShowAdultContentAgeCheckDialog = false
} }
} }
private struct ContentPreferenceState: Equatable {
let isAdultContentVisible: Bool
let contentType: ContentType
}

View File

@@ -0,0 +1,10 @@
import Foundation
struct UpdateContentPreferenceRequest: Encodable {
let isAdultContentVisible: Bool?
let contentType: ContentType?
var isEmpty: Bool {
return isAdultContentVisible == nil && contentType == nil
}
}

View File

@@ -0,0 +1,24 @@
import Foundation
struct UpdateContentPreferenceResponse: Decodable {
let isAdultContentVisible: Bool
let contentType: ContentType
enum CodingKeys: String, CodingKey {
case isAdultContentVisible
case contentType
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
isAdultContentVisible = try container.decodeIfPresent(Bool.self, forKey: .isAdultContentVisible) ?? true
let rawContentType =
try container
.decodeIfPresent(String.self, forKey: .contentType)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
contentType = ContentType(rawValue: rawContentType ?? "") ?? .ALL
}
}

View File

@@ -23,4 +23,55 @@ struct GetMemberInfoResponse: Decodable {
let followingChannelLiveNotice: Bool? let followingChannelLiveNotice: Bool?
let followingChannelUploadContentNotice: Bool? let followingChannelUploadContentNotice: Bool?
let auditionNotice: Bool? let auditionNotice: Bool?
let countryCode: String
let isAdultContentVisible: Bool
let contentType: String
enum CodingKeys: String, CodingKey {
case can
case point
case isAuth
case gender
case signupDate
case chargeCount
case role
case messageNotice
case followingChannelLiveNotice
case followingChannelUploadContentNotice
case auditionNotice
case countryCode
case isAdultContentVisible
case contentType
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
can = try container.decode(Int.self, forKey: .can)
point = try container.decode(Int.self, forKey: .point)
isAuth = try container.decode(Bool.self, forKey: .isAuth)
gender = try container.decodeIfPresent(String.self, forKey: .gender)
signupDate = try container.decode(String.self, forKey: .signupDate)
chargeCount = try container.decode(Int.self, forKey: .chargeCount)
role = try container.decode(MemberRole.self, forKey: .role)
messageNotice = try container.decodeIfPresent(Bool.self, forKey: .messageNotice)
followingChannelLiveNotice = try container.decodeIfPresent(Bool.self, forKey: .followingChannelLiveNotice)
followingChannelUploadContentNotice = try container.decodeIfPresent(Bool.self, forKey: .followingChannelUploadContentNotice)
auditionNotice = try container.decodeIfPresent(Bool.self, forKey: .auditionNotice)
countryCode =
try container
.decodeIfPresent(String.self, forKey: .countryCode)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased() ?? ""
isAdultContentVisible = try container.decodeIfPresent(Bool.self, forKey: .isAdultContentVisible) ?? true
let rawContentType =
try container
.decodeIfPresent(String.self, forKey: .contentType)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
contentType = ["ALL", "MALE", "FEMALE"].contains(rawContentType ?? "") ? (rawContentType ?? "ALL") : "ALL"
}
} }

View File

@@ -16,6 +16,12 @@ struct SettingsView: View {
var body: some View { var body: some View {
let cardWidth = screenSize().width - 26.7 let cardWidth = screenSize().width - 26.7
let isAuth = UserDefaults.bool(forKey: .auth)
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isNonKoreanCountry = !normalizedCountryCode.isEmpty && normalizedCountryCode != "KR"
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
GeometryReader { geo in GeometryReader { geo in
@@ -65,7 +71,7 @@ struct SettingsView: View {
AppState.shared.setAppStep(step: .languageSettings) AppState.shared.setAppStep(step: .languageSettings)
} }
if UserDefaults.bool(forKey: .auth) { if isAuth || isNonKoreanCountry {
Rectangle() Rectangle()
.frame(width: cardWidth - 26.7, height: 0.3) .frame(width: cardWidth - 26.7, height: 0.3)
.foregroundColor(Color.gray90) .foregroundColor(Color.gray90)

View File

@@ -19,6 +19,7 @@ enum UserApi {
case searchUser(nickname: String) case searchUser(nickname: String)
case getMypage case getMypage
case getMemberInfo case getMemberInfo
case updateContentPreference(request: UpdateContentPreferenceRequest)
case notification(request: UpdateNotificationSettingRequest) case notification(request: UpdateNotificationSettingRequest)
case logout case logout
case logoutAllDevice case logoutAllDevice
@@ -78,6 +79,9 @@ extension UserApi: TargetType {
case .getMemberInfo: case .getMemberInfo:
return "/member/info" return "/member/info"
case .updateContentPreference:
return "/member/content-preference"
case .notification: case .notification:
return "/member/notification" return "/member/notification"
@@ -143,6 +147,9 @@ extension UserApi: TargetType {
case .searchUser, .getMypage, .getMemberInfo, .getMyProfile, .getChangeNicknamePrice, .checkNickname, .getBlockedMemberList, .getBlockedMemberIdList, .getMemberProfile: case .searchUser, .getMypage, .getMemberInfo, .getMyProfile, .getChangeNicknamePrice, .checkNickname, .getBlockedMemberList, .getBlockedMemberIdList, .getMemberProfile:
return .get return .get
case .updateContentPreference:
return .patch
case .updatePushToken, .profileUpdate, .changeNickname, .updateIdfa, .updateMarketingInfo: case .updatePushToken, .profileUpdate, .changeNickname, .updateIdfa, .updateMarketingInfo:
return .put return .put
} }
@@ -183,6 +190,9 @@ extension UserApi: TargetType {
case .notification(let request): case .notification(let request):
return .requestJSONEncodable(request) return .requestJSONEncodable(request)
case .updateContentPreference(let request):
return .requestJSONEncodable(request)
case .signOut(let request): case .signOut(let request):
return .requestJSONEncodable(request) return .requestJSONEncodable(request)

View File

@@ -53,6 +53,10 @@ final class UserRepository {
return api.requestPublisher(.getMemberInfo) return api.requestPublisher(.getMemberInfo)
} }
func updateContentPreference(request: UpdateContentPreferenceRequest) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.updateContentPreference(request: request))
}
func getMemberCan() -> AnyPublisher<Response, MoyaError> { func getMemberCan() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getMemberInfo) return api.requestPublisher(.getMemberInfo)
} }

View File

@@ -0,0 +1,188 @@
# 20260324 라이브룸 캡쳐/녹화 보안 및 오디오 차단 통합 계획
## 작업 체크리스트
- [x] `LiveRoomViewV2`의 캡쳐/녹화 감지 및 기존 음소거 제어 포인트 확인
- [x] 캡쳐/녹화 시작 시 화면 검정 오버레이 적용
- [x] 캡쳐/녹화 시작 시 음소거 강제 적용
- [x] 종료 시 화면/음소거 상태 복원 로직 점검
- [x] 진단/빌드/테스트 검증 수행 및 기록
## 수용 기준 (Acceptance Criteria)
- [x] `UIScreen.main.isCaptured == true` 상태에서 라이브룸 주요 콘텐츠 위에 검정 화면이 표시된다.
- [x] 캡쳐/녹화 상태 진입 시 스피커 출력이 음소거된다.
- [x] 내가 스피커 역할일 경우, 캡쳐/녹화 상태 진입 시 마이크도 음소거된다.
- [x] 캡쳐/녹화 상태 해제 시 사용자의 기존 음소거 상태를 유지/복원한다.
## 후속 작업 체크리스트 (화면 캡쳐 미차단)
- [x] 화면 녹화와 화면 캡쳐의 동작 차이를 iOS 시스템 제약 관점에서 확인
- [x] 코드베이스 내 캡쳐 차단/보호 패턴 유무 조사
- [x] 가능한 최소 수정안 적용 (캡쳐 시 검정 처리 보강)
- [x] LSP/빌드/테스트 및 수동 QA 결과 기록
## 후속 수용 기준 (Pass/Fail)
- [x] 원인: 화면 캡쳐 시 기존 로직이 검정 화면을 만들지 못한 이유를 코드와 플랫폼 제약으로 설명 가능
- [x] 조치: 캡쳐 시점에도 검정 처리(또는 동등한 보호)가 적용되는 코드가 반영됨
- [x] 안정성: 수정 파일 `lsp_diagnostics` 무오류
- [x] 회귀: `SodaLive`, `SodaLive-dev` Debug build 성공
## 검증 기록
### 1차 검증 (2026-03-24)
- 무엇/왜/어떻게:
- 무엇: `LiveRoomViewV2`에 캡쳐/녹화 감지, 검정 오버레이, 음소거 강제/복원 로직을 추가하고 회귀를 확인.
- 왜: 요청사항(검정 캡쳐 + 음소거) 충족과 기존 동작 안정성 보장.
- 어떻게: LSP 진단, 스킴 빌드, 테스트 액션 실행 결과를 기록하고 수동 확인 가능 범위를 점검.
- 실행 명령:
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- `lsp_diagnostics`: `No diagnostics found`
- `SodaLive` 빌드: `** BUILD SUCCEEDED **` (병렬 빌드 시 1회 `build.db locked` 발생 후 단독 재실행으로 성공)
- `SodaLive-dev` 빌드: `** BUILD SUCCEEDED **`
- `SodaLive` 테스트: `Scheme SodaLive is not currently configured for the test action.`
- `SodaLive-dev` 테스트: `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA: 현재 CLI/헤드리스 환경에서는 실제 기기/시뮬레이터에서 화면 캡쳐·녹화 시작 이벤트를 직접 조작하는 E2E 검증이 제한되어, 코드 경로(`UIScreen.capturedDidChangeNotification` 수신 시 검정 오버레이 + 음소거 적용, 해제 시 복원)까지 확인.
### 2차 검증 (2026-03-24) — 화면 캡쳐 미차단 후속 대응
- 무엇/왜/어떻게:
- 무엇: 화면 캡쳐가 그대로 저장되는 원인을 확인하고, `LiveRoomViewV2` 전체를 보안 컨테이너(`isSecureTextEntry` 기반)로 감싸 캡쳐 보호를 보강.
- 왜: `UIScreen.main.isCaptured`/`capturedDidChangeNotification`은 녹화·미러링 상태 변화에는 반응하지만, 스크린샷은 사후 알림(`userDidTakeScreenshotNotification`)만 가능해 기존 오버레이 방식으로는 캡쳐본을 검정으로 바꾸지 못하기 때문.
- 어떻게: 내부 패턴 탐색(explore) + iOS 공식 동작 조사(librarian)로 원인을 확정한 뒤, `ScreenCaptureSecureContainer``LiveRoomViewV2` body 루트에 적용.
- 실행 명령:
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- `lsp_diagnostics`: `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA: 현재 CLI 환경에서는 라이브룸 실화면에서 직접 스크린샷/녹화를 트리거하는 E2E 자동화가 불가하여, 기기/시뮬레이터에서 최종 캡쳐 결과 확인이 필요함.
- 참고: `isSecureTextEntry` 기반 전체 뷰 보호는 실무적으로 사용되는 우회 방식이며, iOS 버전별 동작 차이가 있을 수 있어 실제 단말 검증을 필수로 유지.
## 후속 작업 체크리스트 (확정 이슈/보강 통합)
- [x] `LiveRoomViewV2` 캡쳐 보호 상태에서 `.overlay` 이펙트 렌더링 차단
- [x] 캡쳐 보호 오버레이가 터치를 통과시키지 않도록 입력 차단 유지
- [x] 캡쳐 해제 시 마이크 복원 로직의 role 의존 조건 제거/보완
- [x] `ScreenCaptureSecureView.setup()` secure 컨테이너 탐색 흐름 점검
- [x] secure 컨테이너 탐색 실패 시 fail-open(`UITextField` 직접 사용) 제거 및 fail-closed 처리 적용
- [x] 관련 진단/빌드/테스트 및 수동 QA 결과 누적 기록
## 후속 수용 기준 (확정 이슈/보강 Pass/Fail)
- [x] Pass: `isScreenCaptureProtected == true`일 때 검정 보호 레이어 최상단 노출 상태에서 하트/파티클 오버레이가 렌더링되지 않는다.
- QA: `.overlay(alignment: .center)` 내부를 `if !isScreenCaptureProtected`로 가드해 보호 상태에서 오버레이 뷰 트리를 생성하지 않음을 코드 레벨로 확인.
- [x] Pass: `isScreenCaptureProtected == false`일 때 기존 하트/파티클 오버레이 동작이 유지된다.
- QA: 보호 해제 상태에서 기존 `WaterHeartView`, `bigHeartParticles` 렌더 경로가 동일하게 남아 있음을 확인.
- [x] Pass: `isScreenCaptureProtected == true`일 때 하위 UI 상호작용이 차단된다.
- QA: 오버레이에 `.allowsHitTesting(true)`가 적용되어 입력을 오버레이가 수신하는지 확인.
- [x] Pass: 캡쳐 해제 시 role 상태와 무관하게 강제 마이크 mute 복원이 누락되지 않는다.
- QA: `releaseForcedCaptureMute()`에서 `shouldRestoreMicMuteAfterCapture` 경로가 role 조건 없이 동작하는지 확인.
- [x] Pass: secure 컨테이너 탐색 실패 시 일반 계층으로 폴백하지 않고 fail-closed(검정 오버레이 유지)로 동작한다.
- QA: `secureTextField.subviews.first ?? secureTextField` 폴백 제거 및 secure canvas 탐색 실패 시 콘텐츠 미탑재/오버레이 활성화를 코드 레벨로 확인.
### 3차 검증 (2026-03-24) — 캡쳐 보호 오버레이 렌더 차단
- 무엇/왜/어떻게:
- 무엇: 캡쳐 보호 상태에서도 오버레이 하트/파티클이 노출될 수 있는 경로를 차단.
- 왜: 보호 상태의 화면 노출 가능성을 제거해 보안/정합성 리스크를 낮추기 위해.
- 어떻게: `LiveRoomViewV2.swift``.overlay` 렌더링을 `isScreenCaptureProtected`와 연동해 보호 시 비활성화하고, 정적 진단/빌드/테스트 액션을 검증.
- 실행 명령:
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift, severity: all)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- `lsp_diagnostics`: `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: 두 스킴 모두 `is not currently configured for the test action`로 테스트 액션 미구성 확인
### 4차 검증 (2026-03-24) — 캡쳐 보호 확정 이슈 패치
- 무엇/왜/어떻게:
- 무엇: 캡쳐 보호 중 입력 차단과 마이크 복원 누락 이슈를 패치.
- 왜: 확정된 중간 심각도 안정성 이슈를 제거하기 위해.
- 어떻게: 코드 수정 후 LSP 진단, 스킴 빌드, 테스트 액션, 수동 QA 가능 범위를 기록.
- 실행 명령:
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift, severity: all)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- `Read(SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift:850~874, 1248~1271)`
- 결과:
- `lsp_diagnostics`: `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA(가능 범위):
- 오버레이 경로 확인: `if isScreenCaptureProtected { Color.black ... .allowsHitTesting(true) }`
- 복원 경로 확인: `if shouldRestoreMicMuteAfterCapture { if viewModel.isMute { viewModel.toggleMute() } ... }`
- 현재 CLI/헤드리스 환경에서는 실제 라이브룸 진입 후 캡쳐·녹화 이벤트를 조작하는 디바이스 E2E 검증이 제한됨.
### 5차 검증 (2026-03-24) — secure 컨테이너 폴백 fail-closed 보강
- 무엇/왜/어떻게:
- 무엇: `ScreenCaptureSecureView`에서 `secureTextField.subviews.first ?? secureTextField` 폴백을 제거하고, secure canvas 식별 실패 시 검정 fail-closed 오버레이를 유지하도록 변경.
- 왜: secure 렌더링 컨테이너 탐색 실패 시 일반 계층으로 콘텐츠가 붙어 캡쳐 보호가 무력화될 수 있는 fail-open 동작을 차단하기 위해.
- 어떻게: `CanvasView` 클래스명 기반 secure 컨테이너 탐색 + 실패 시 `ERROR_LOG` 1회 기록 및 콘텐츠 미탑재 처리.
- 실행 명령:
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- `lsp_diagnostics`: `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
## 통합 이력 (중복 문서 병합)
- 본 문서는 아래 연속 수정 문서를 하나로 통합한 기준 문서다.
- `docs/20260324_캡쳐보호초기진입음소거보완.md`
- `docs/20260324_라이브녹화오디오차단수정.md`
## 통합 작업 체크리스트 (추가)
- [x] `LiveRoomViewModel`에 idempotent 음소거 setter(`setMute`, `setSpeakerMute`) 추가
- [x] 캡쳐 보호 로직에서 toggle 호출을 setter 호출로 전환
- [x] 라이브룸 진입 시 Agora 엔진 초기화와 캡쳐 보호 적용 순서 보강
- [x] 라이브 캡쳐/녹화 시 오디오 녹음 경로 원인 분석 (코드+외부 문서)
- [x] 기존 캡쳐 보호 음소거 로직의 연결 타이밍/상태 전이 검증
- [x] 최소 수정으로 오디오 차단 누락 경로 패치
## 통합 수용 기준 (추가)
- [x] 캡쳐가 이미 활성화된 상태로 라이브룸 진입해도 원격 오디오 음소거가 누락되지 않는다.
- [x] 캡쳐 보호 진입/해제 시 마이크·스피커 음소거 상태가 토글 누적 없이 일관되게 유지/복원된다.
- [x] 영상 보호(검정/보안 컨테이너) 상태에서 라이브 오디오가 녹화에 남지 않는다.
- [x] 초기 진입/연결 완료/역할 전환 시점 모두에서 캡쳐 보호 음소거가 일관 적용된다.
- [x] 변경 파일 `lsp_diagnostics` 무오류 및 `SodaLive`/`SodaLive-dev` Debug build 성공.
## 통합 검증 기록 (추가)
### 6차 검증 (2026-03-24) — 초기 진입 캡쳐 보호 음소거 보완
- 무엇/왜/어떻게:
- 무엇: `LiveRoomViewModel`에 상태 기반 음소거 setter를 추가하고, `LiveRoomViewV2` 캡쳐 보호 경로를 toggle 호출에서 setter 호출로 전환.
- 왜: 캡쳐가 이미 켜진 상태로 화면 진입 시 Agora 엔진 초기화 타이밍 때문에 스피커 강제 음소거가 누락될 수 있는 경로를 제거하기 위해.
- 어떻게: `onAppear`에서 `initAgoraEngine()` 호출을 선행시키고, 캡쳐 보호 진입/복원 및 role 변경 경로를 idempotent setter 기반으로 정리.
- 실행 명령:
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift, severity: all)`
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift, severity: all)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- `lsp_diagnostics`: 두 파일 모두 `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA: 현재 CLI/헤드리스 환경에서는 실제 단말에서 화면 녹화/미러링 상태로 진입하는 E2E 조작이 제한되어 코드 경로 기준으로 검증함.
### 7차 검증 (2026-03-24) — 녹화 오디오 차단 보강
- 무엇/왜/어떻게:
- 무엇: `agoraConnectSuccess` 시점에 현재 음소거 상태(`isSpeakerMute`, `isMute`)를 Agora 엔진에 재적용하고, `applyScreenCaptureProtection(isCaptured: true)`에서 role 조건을 제거해 캡쳐 상태면 마이크를 선제 음소거하도록 보강.
- 왜: 캡쳐 상태에서 선행 음소거 이후 채널 join/rejoin 기본 구독 복귀, listener→speaker 전환 시점의 짧은 오디오 누출 창을 동시에 차단하기 위해.
- 어떻게: 코드베이스 탐색(explore 2건) + 외부 문서 조사(librarian 1건)로 원인을 확정하고 최소 패치 적용.
- 실행 명령:
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift, severity: all)`
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift, severity: all)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- `Read(SodaLive/Sources/Live/Room/LiveRoomViewModel.swift:519~533)`
- `grep("func agoraConnectSuccess|if isSpeakerMute|if isMute", LiveRoomViewModel.swift)`
- `grep("func applyScreenCaptureProtection|if !viewModel.isMute|shouldRestoreMicMuteAfterCapture = true", LiveRoomViewV2.swift)`
- 결과:
- `lsp_diagnostics`: 두 파일 모두 `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA(가능 범위):
- 캡쳐 진입 시 role과 무관한 마이크 선제 음소거 경로(`if !viewModel.isMute`) 확인.
- 연결 완료 시 `isSpeakerMute/isMute` 상태 재적용 경로 확인.
- CLI 한계로 실기기 녹화 E2E는 별도 디바이스 수동 검증 필요.

View File

@@ -0,0 +1,70 @@
# 20260324 라이브 상세 SNS 아이콘 적용
## 작업 항목
- [x] `LiveDetailView` SNS 영역 아이콘을 `CreatorDetailDialogView`에서 사용하는 SNS 아이콘 에셋으로 변경
- QA: `instagram`, `fancimm`, `x`, `youtube`, `kakaoOpenChat` URL이 유효할 때만 아이콘이 표시된다.
- [x] `GetRoomDetailManager` 신규 SNS 필드(`youtube`, `instagram`, `x`, `fancimm`, `kakaoOpenChat`)를 모두 라이브 상세 SNS 영역에 반영
- QA: 5개 SNS가 누락 없이 표시 대상에 포함된다.
- [x] SNS 아이콘 크기를 기존과 동일하게 유지
- QA: 아이콘 `frame(width: 33.3, height: 33.3)`를 유지한다.
## 검증 항목
- [x] `lsp_diagnostics`로 변경 파일 오류 확인
- [x] `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` 실행
## 검증 기록
- 일시: 2026-03-24
- 무엇: `LiveDetailView` SNS 아이콘 영역 변경 후 정적 진단
- 왜: 변경 파일의 컴파일/타입 문제 확인
- 어떻게: LSP 진단 실행
- 실행 명령: `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift)`
- 결과: `No such module 'Kingfisher'` 1건 확인(로컬 SourceKit 인덱싱 환경 이슈로 판단, 코드 문법 오류는 확인되지 않음)
- 일시: 2026-03-24
- 무엇: 앱 빌드 검증
- 왜: 수정 사항이 실제 프로젝트 빌드에 문제 없는지 확인
- 어떻게: Debug 빌드 수행
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- 결과: `** BUILD SUCCEEDED **`
- 일시: 2026-03-24
- 무엇: 테스트 실행 가능 여부 확인
- 왜: 변경 후 회귀 테스트 수행 시도
- 어떻게: 스킴 test 액션 실행
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- 결과: `Scheme SodaLive is not currently configured for the test action.` (현재 스킴 테스트 미구성)
- 일시: 2026-03-24
- 무엇: 최종 수정(들여쓰기 정리) 후 재빌드 확인
- 왜: 최종 상태 기준으로 컴파일 성공 재확인
- 어떻게: Debug 빌드 재실행
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- 결과: `** BUILD SUCCEEDED **`
- 일시: 2026-03-24
- 무엇: 최종 수정 후 테스트 액션 재확인
- 왜: 최종 상태 기준 테스트 가능 여부 재확인
- 어떻게: 스킴 test 액션 재실행
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- 결과: `Scheme SodaLive is not currently configured for the test action.` (현재 스킴 테스트 미구성)
- 일시: 2026-03-24
- 무엇: SNS 매핑/크기 수동 QA(정적 확인)
- 왜: 요청한 5개 SNS 반영 및 아이콘 크기 유지 여부를 최종 확인
- 어떻게: 소스 패턴 검색으로 매핑 개수와 뷰 프레임 확인
- 실행 명령: `grep count: appendSnsItem(... "ic_sns_", ...)`, `grep content: ForEach(makeSnsItems...), .frame(width: 33.3, height: 33.3)`
- 결과: `appendSnsItem` 5건 확인, SNS 렌더링 `ForEach` + `frame(width: 33.3, height: 33.3)` 확인
- 일시: 2026-03-24
- 무엇: SNS 순서 정리 후 최종 빌드
- 왜: 최종 코드 기준 컴파일 성공 여부 확정
- 어떻게: Debug 빌드 재실행
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- 결과: `** BUILD SUCCEEDED **`
- 일시: 2026-03-24
- 무엇: SNS 순서 정리 후 최종 테스트 액션 확인
- 왜: 최종 코드 기준 테스트 실행 가능 여부 확정
- 어떻게: 스킴 test 액션 재실행
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- 결과: `Scheme SodaLive is not currently configured for the test action.` (현재 스킴 테스트 미구성)

View File

@@ -0,0 +1,37 @@
# 20260324 라이브 상세 복귀 시 DIM만 보이는 문제 수정 계획
## 작업 체크리스트
- [x] 라이브 상세 복귀 시 상세 패널 비노출 원인 분석
- [x] `LiveDetailView` 복귀 시점 표시 상태 복원 로직 구현
- [x] 원인과 수정안 문서화
- [x] 진단/빌드/테스트 검증 수행 및 기록
## 원인 분석
- `LiveDetailView`가 자체 `LiveDetailViewModel``@ObservedObject`로 생성하고 있었다.
- 이 뷰는 `ContentView`의 전역 오버레이(`appState.liveDetailSheet`)로 표시되며, 채널 보기 이동(`AppState.shared.setAppStep(step: .creatorDetail)`)이나 앱 활성/비활성 전환 시 부모 뷰가 다시 그려진다.
- 재그리기 시 `@ObservedObject` 인스턴스가 재생성되면 `showDetail`이 기본값(`false`)로 돌아가고, 하단 패널은 `offset(y: viewModel.showDetail ? 0 : proxy.size.height * 0.9)` 때문에 화면 밖으로 내려간다.
- DIM 레이어(`Color.black.opacity(0.7)`)는 `showDetail`과 무관하게 항상 렌더링되므로, 결과적으로 "상세 패널은 사라지고 DIM만 보이는" 상태가 발생한다.
## 수정안
- `SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift`
- `@ObservedObject var viewModel = LiveDetailViewModel()`
-`@StateObject private var viewModel = LiveDetailViewModel()`로 변경.
- 상세 시트를 소유하는 뷰에서 ViewModel 인스턴스를 유지하도록 바꿔, 페이지 이동/복귀 및 라이프사이클 변화 시에도 `showDetail` 상태가 초기화되지 않도록 했다.
- 기존 UI 구조, 애니메이션, 닫기 로직은 변경하지 않고 상태 소유 방식만 최소 수정했다.
## 검증 기록
### 1차 검증 (2026-03-24)
- 무엇/왜/어떻게:
- 무엇: `LiveDetailView`의 ViewModel 소유 방식을 `@StateObject`로 변경.
- 왜: 복귀 시 `showDetail` 초기화로 패널이 숨겨지고 DIM만 남는 문제를 방지하기 위함.
- 어떻게: 코드 수정 후 진단/빌드/테스트 엔트리 포인트 명령으로 회귀 여부 확인.
- 실행 명령:
- `lsp_diagnostics(file: SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 결과:
- `lsp_diagnostics`: `No such module 'Kingfisher'` 1건(현재 SourceKit 환경 제약으로 인한 기존 이슈).
- `SodaLive` 빌드: `** BUILD SUCCEEDED **`
- `SodaLive` 테스트: `Scheme SodaLive is not currently configured for the test action.`
- `SodaLive-dev` 빌드: `** BUILD SUCCEEDED **`

View File

@@ -0,0 +1,178 @@
# 20260326 회원정보 응답 확장 및 콘텐츠 보기 설정 연동
## 개요
- `/member/info` 응답 확장 필드(`countryCode`, `isAdultContentVisible`, `contentType`)를 앱 상태에 반영한다.
- 설정 화면의 `콘텐츠 보기 설정` 메뉴 노출 조건을 기존 `인증 사용자` 기준에서 `인증 사용자 또는 비한국 국가 코드`까지 확장한다.
- 콘텐츠 보기 설정 변경 시 `/member/content-preference`(`PATCH`)를 호출해 서버와 클라이언트 값을 동기화한다.
- 설정값 연타 시 `debounce`로 마지막 변경값만 서버에 전송하고, 전송 중에는 로딩 다이얼로그를 노출한다.
## 요구사항 요약
- `GET /member/info`
- 추가 응답 필드
- `countryCode: String` (접속 국가 코드)
- `isAdultContentVisible: Boolean`
- `contentType: ContentType`
- 메뉴 노출 규칙
- 현재 유지: `UserDefaults.bool(forKey: .auth) == true`이면 노출
- 추가: `countryCode != "KR"`인 경우에도 노출
- `PATCH /member/content-preference`
- 콘텐츠 보기 설정 변경 시 호출
- 응답 필드
- `isAdultContentVisible: Boolean`
- `contentType: ContentType`
- UX
- API 호출 시 Loading Dialog 표시
- 연속 입력 시 마지막 값만 서버 전송
## 완료 기준 (Acceptance Criteria)
- [x] AC1: `GetMemberInfoResponse``countryCode`, `isAdultContentVisible`, `contentType`를 디코딩한다.
- [x] AC2: `HomeViewModel.getMemberInfo`, `AppViewModel.getMemberInfo`에서 신규 필드가 `UserDefaults`에 저장된다.
- [x] AC3: `SettingsView``콘텐츠 보기 설정` 메뉴가 `auth == true || normalizedCountryCode != "KR"` 조건에서 노출된다.
- [x] AC4: `ContentSettingsView` 내 토글/라디오 변경 시 `/member/content-preference` `PATCH`가 호출된다.
- [x] AC5: 콘텐츠 설정 API 호출 중 `LoadingView`가 표시되고, 완료/실패 시 정상 해제된다.
- [x] AC6: 짧은 시간 내 연타(토글/라디오 연속 변경) 시 마지막 상태 1건만 전송된다.
- [x] AC7: 서버 응답 성공 시 로컬(`UserDefaults`) 상태가 최종값과 일치한다.
## 구현 체크리스트
### 1) 회원정보 응답 모델/저장 키 확장
- [x] `SodaLive/Sources/Settings/Notification/GetMemberInfoResponse.swift`
- `countryCode`, `isAdultContentVisible`, `contentType` 필드 추가
- 기존 디코딩 영향(옵셔널/기본값 정책) 점검
- [x] `SodaLive/Sources/Extensions/UserDefaultsExtension.swift`
- `UserDefaultsKey`에 국가 코드 저장 키 추가(예: `countryCode`)
### 2) `/member/info` 수신 데이터 저장 경로 확장
- [x] `SodaLive/Sources/Main/Home/HomeViewModel.swift`
- `getMemberInfo()` 성공 시 신규 3개 필드 저장 로직 추가
- [x] `SodaLive/Sources/App/AppViewModel.swift`
- `getMemberInfo()` 성공 시 신규 3개 필드 저장 로직 추가
- [x] 저장 정책 정리
- 국가 코드는 대문자 정규화(`uppercased`) 후 저장
- `contentType`는 서버값 우선 저장, 미존재/비정상 값은 `ALL` fallback 검토
### 3) 설정 메뉴 노출 조건 확장
- [x] `SodaLive/Sources/Settings/SettingsView.swift`
- 기존 `if UserDefaults.bool(forKey: .auth)` 조건을
`if isAuth || isNonKoreanCountry` 형태로 확장
- `isNonKoreanCountry` 계산 시 공백/소문자 입력 대비 정규화 처리
### 4) 콘텐츠 설정 PATCH API 추가
- [x] `SodaLive/Sources/User/UserApi.swift`
- `case updateContentPreference(request: UpdateContentPreferenceRequest)` 추가
- `path`: `/member/content-preference`
- `method`: `.patch`
- `task`: `.requestJSONEncodable(request)`
- [x] `SodaLive/Sources/User/UserRepository.swift`
- `updateContentPreference(...)` 메서드 추가
- [x] 신규 DTO 추가
- [x] `SodaLive/Sources/Settings/Content/UpdateContentPreferenceRequest.swift`
- [x] `SodaLive/Sources/Settings/Content/UpdateContentPreferenceResponse.swift`
### 5) 콘텐츠 설정 화면 상태/전송 로직 보강
- [x] `SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift`
- `@Published isLoading`, `errorMessage`, `isShowPopup` 추가
- 토글/라디오 변경 이벤트를 `Subject`로 수집
- `debounce` + `removeDuplicates`로 마지막 값만 전송
- API 성공 시 응답값 기준으로 로컬 상태 최종 확정
- API 실패 시 에러 토스트 노출 및 로딩 해제
- [x] `SodaLive/Sources/Settings/Content/ContentSettingsView.swift`
- `BaseView(isLoading: $viewModel.isLoading)` 적용으로 Loading Dialog 표시
- `.sodaToast(...)` 연결로 실패 메시지 표시
### 6) 회귀 영향 점검
- [x] `UserDefaults.isAdultContentVisible()` 및 기존 콘텐츠 조회 API 파라미터 경로(`HomeTabRepository`, `ContentRepository`, `SearchRepository` 등)에서 신규 저장값 반영 여부 점검
- [x] 앱 재시작 플래그(`AppState.shared.isRestartApp`)와 서버 동기화 타이밍 충돌 여부 점검
### 7) 검증 계획
- [x] 정적 진단: 수정 파일 `lsp_diagnostics` 확인
- [x] 빌드: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- [x] 빌드(개발): `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` / `SodaLive-dev test`
- [ ] 수동 QA
- [ ] 한국 계정(`countryCode == KR`, 미인증): 메뉴 비노출
- [ ] 비한국 계정(`countryCode != KR`, 미인증): 메뉴 노출
- [ ] 인증 계정(`isAuth == true`): 국가코드 무관 메뉴 노출
- [ ] 토글/라디오 연타 시 마지막 선택값만 서버 반영
- [ ] API 호출 중 로딩 다이얼로그 표시 및 완료 후 해제
### 8) 국가 기반 성인 접근 분기 및 18+ 확인 팝업
- [x] `SodaLive/Sources/Main/Home/HomeView.swift`
- 성인 라이브 진입 시 국가코드 분기 적용
- `KR(또는 빈값)` + 미인증: 기존 본인인증 팝업 유지
- `non-KR` + 민감 콘텐츠 OFF: `contentViewSettings` 이동 + 안내 팝업 노출
- [x] `SodaLive/Sources/Live/Now/All/LiveNowAllView.swift`
- `HomeView`와 동일한 국가 분기/가드 정책 반영
- [x] `SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift`
- 민감 콘텐츠 ON 전 18+ 확인 상태 추가
- `handleAdultContentToggleTap`, `confirmAdultContentAgeCheck`, `cancelAdultContentAgeCheck` 구현
- [x] `SodaLive/Sources/Settings/Content/ContentSettingsView.swift`
- 스위치 탭 동작을 뷰모델 핸들러로 연결
- `SodaDialog``아니오/예` 처리 연결(예: ON + API 흐름, 아니오: OFF 유지)
- [x] `SodaLive/Sources/I18n/I18n.swift`
- `adultContentAgeCheckTitle`, `adultContentAgeCheckDesc`, `adultContentEnableGuide` 국제화 문자열 추가
- [x] `SodaLive/Sources/Live/Room/LiveRoomViewModel.swift`
- `requiresAdultAuthenticationByCountry()` 도입
- 성인 방 진입 시 인증 요구 조건을 KR 기반으로 일관화
## 영향 파일(예상)
- `SodaLive/Sources/Settings/Notification/GetMemberInfoResponse.swift`
- `SodaLive/Sources/Extensions/UserDefaultsExtension.swift`
- `SodaLive/Sources/Main/Home/HomeViewModel.swift`
- `SodaLive/Sources/App/AppViewModel.swift`
- `SodaLive/Sources/Settings/SettingsView.swift`
- `SodaLive/Sources/User/UserApi.swift`
- `SodaLive/Sources/User/UserRepository.swift`
- `SodaLive/Sources/Settings/Content/ContentSettingsView.swift`
- `SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift`
- `SodaLive/Sources/Settings/Content/UpdateContentPreferenceRequest.swift` (신규)
- `SodaLive/Sources/Settings/Content/UpdateContentPreferenceResponse.swift` (신규)
## 리스크 및 의존성
- 백엔드가 `contentType` 문자열을 `ALL/MALE/FEMALE` 외 값으로 내려주면 디코딩 실패 가능성이 있어 방어 로직이 필요하다.
- `/member/content-preference` 응답/에러 정책이 미정이면 실패 시 롤백 기준(로컬 유지/복구) 정의가 필요하다.
- `countryCode` 미수신 시 기본 노출 정책(비노출 권장)을 명확히 정해야 메뉴 오노출을 방지할 수 있다.
## 검증 기록
- 일시: 2026-03-26
- 무엇: 회원정보 응답 확장/콘텐츠 설정 서버 동기화 작업을 위한 구현 계획 문서 작성
- 왜: 요청 범위(응답 필드 확장, 메뉴 노출 조건 변경, PATCH 연동, 로딩/디바운스)를 코드 경로 기준으로 실행 가능한 체크리스트로 정리하기 위함
- 어떻게: 기존 구현 파일(`UserApi`, `UserRepository`, `GetMemberInfoResponse`, `SettingsView`, `ContentSettingsViewModel`)과 기존 계획 문서 포맷을 조사해 항목화
- 실행 명령/도구: `read(docs/*)`, `grep("/member/info|getMemberInfo|isAdultContentVisible|contentType|debounce")`, `read(UserApi.swift, UserRepository.swift, SettingsView.swift, ContentSettingsViewModel.swift 등)`
- 결과: 구현 전용 체크리스트/완료 기준/검증 계획/리스크가 포함된 계획 문서 초안 작성 완료
- 일시: 2026-03-26
- 무엇: 회원정보 응답 확장 및 콘텐츠 보기 설정 서버 동기화 구현 완료
- 왜: `/member/info` 확장 필드 반영, 설정 메뉴 노출 조건 확장, `/member/content-preference` PATCH 연동, debounce/로딩 UX 요구사항을 충족하기 위함
- 어떻게: `GetMemberInfoResponse`/`UserDefaultsKey` 확장, `HomeViewModel`/`AppViewModel` 저장 로직 보강, `SettingsView` 노출 조건 변경, `UserApi`/`UserRepository` PATCH 추가, `ContentSettingsViewModel` Subject+debounce 동기화 및 `ContentSettingsView` 로딩/토스트 연결
- 실행 명령/도구:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- `lsp_diagnostics(수정 파일)`
- 결과:
- 두 스킴 Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트 명령은 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 타깃 미구성)
- `lsp_diagnostics`는 SourceKit 해석 범위 한계로 다수의 모듈 미해결 오류를 반환했으나, 실제 Xcode 빌드는 통과하여 컴파일 정상 확인
- 수동 QA는 현재 CLI 환경 한계로 미실행(체크리스트 유지)
- 일시: 2026-03-27
- 무엇: 국가 기반 성인 접근 분기 및 민감 콘텐츠 ON 18+ 확인 팝업 구현 검증
- 왜: 한국/비한국 정책 분기와 민감 콘텐츠 ON 보호 UX(국제화 포함) 요구사항을 반영하고, 실제 빌드 기준으로 회귀 여부를 확인하기 위함
- 어떻게:
- `HomeView`, `LiveNowAllView` 성인 진입 가드에 국가코드 분기 추가
- `ContentSettingsViewModel`/`ContentSettingsView`에 18+ 확인 다이얼로그 플로우 추가
- `I18n.Settings`에 신규 문구 추가 및 `LiveRoomViewModel` 성인 인증 조건을 KR 기반으로 정렬
- 실행 명령/도구:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- `lsp_diagnostics(I18n.swift, ContentSettingsViewModel.swift, ContentSettingsView.swift, HomeView.swift, LiveNowAllView.swift, LiveRoomViewModel.swift)`
- 결과:
- `SodaLive` 빌드는 최초 병렬 실행 시 `build.db` lock으로 실패했으나, 단독 재실행에서 `BUILD SUCCEEDED`
- `SodaLive-dev` 빌드는 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 모듈 해석 제약으로 다수 에러를 보고했으나, 실제 `xcodebuild` 통과로 컴파일 정상 확인
- 수동 QA는 CLI 환경 한계로 미실행(체크리스트 유지)

View File

@@ -0,0 +1,67 @@
# 20260327 라이브 생성·콘텐츠 업로드 연령제한 표시 조건 수정
## 개요
- 라이브 생성 페이지와 콘텐츠 업로드 페이지의 연령제한 설정 UI 표시 조건을 정책에 맞게 통일한다.
- 표시 조건은 `isAdultContentVisible`를 필수로 하고, 접속국가가 한국(`KR` 또는 빈값)일 때만 `isAuth`를 추가로 요구한다.
## 요구사항 요약
- 대상 화면:
- 라이브 생성 (`LiveRoomCreateView`)
- 콘텐츠 업로드 (`ContentCreateView`)
- 표시 조건:
- 필수: `isAdultContentVisible == true`
- 추가: 접속국가가 한국이면 `isAuth == true`
## 완료 기준 (Acceptance Criteria)
- [x] AC1: 라이브 생성 화면 연령제한 설정 UI가 `isAdultContentVisible == true`일 때만 표시된다.
- [x] AC2: 라이브 생성 화면에서 접속국가가 한국이면 `isAuth == true`일 때만 연령제한 설정 UI가 표시된다.
- [x] AC3: 콘텐츠 업로드 화면 연령제한 설정 UI가 동일 조건(`isAdultContentVisible` 필수 + 한국일 때 `isAuth` 추가)으로 표시된다.
- [x] AC4: 국가코드 판별은 기존 관례(공백 제거 + 대문자, 빈값은 한국 취급)를 따른다.
- [x] AC5: 수정 파일 진단, 빌드/테스트 결과와 한계를 문서 하단 검증 기록에 남긴다.
## 구현 체크리스트
- [x] 라이브 생성/콘텐츠 업로드 기존 UI 분기 코드 위치 확인
- [x] 공통 표시 조건 계산식 정의 (`isAdultContentVisible`, `isKoreanCountry`, `isAuth`)
- [x] 라이브 생성 화면 조건 적용
- [x] 콘텐츠 업로드 화면 조건 적용
- [x] 수정 파일 진단 및 워크스페이스 빌드/테스트 실행
- [x] 문서 체크리스트/검증 기록 업데이트
## 검증 계획
- [x] 정적 진단: `lsp_diagnostics` (수정 파일 전체)
- [x] 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-27
- 무엇: 라이브 생성/콘텐츠 업로드 연령제한 UI 표시 조건 수정 계획 문서 작성
- 왜: 구현 범위와 완료 기준을 먼저 고정해 요청된 조건만 정확히 반영하기 위함
- 어떻게: docs 규칙에 맞춰 요구사항, 완료 기준, 검증 계획을 체크리스트로 문서화
- 실행 명령/도구: `apply_patch(문서 생성)`
- 결과: 계획 문서 생성 완료
- 일시: 2026-03-27
- 무엇: 라이브 생성/콘텐츠 업로드 연령제한 UI 표시 조건을 국가/설정 기반으로 수정 및 검증
- 왜: 기존 조건(인증 기반 또는 무조건 노출)을 정책(`isAdultContentVisible` 필수 + 한국일 때 `isAuth` 필수)과 일치시키기 위함
- 어떻게:
- `LiveRoomCreateView``isKoreanCountry`, `isAdultContentVisible`, `shouldShowAdultSetting` 계산 추가 후 `AdultSettingView` 표시 분기 교체
- `ContentCreateView`에 동일 계산 추가 후 연령 제한 섹션 전체를 `if shouldShowAdultSetting`으로 감싸 조건부 렌더링 적용
- 국가코드는 기존 관례대로 `trim + uppercased`, 빈값은 한국 취급
- 실행 명령/도구:
- 진단: `lsp_diagnostics(LiveRoomCreateView.swift)`, `lsp_diagnostics(ContentCreateView.swift)`
- 빌드: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 테스트 시도: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 로직 수동 검증(CLI): `xcrun swift -e "import Foundation; ... shouldShow(...) ..."`
- 결과:
- 두 스킴 Debug 빌드 모두 `** BUILD SUCCEEDED **`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가
- `lsp_diagnostics`는 SourceKit 환경에서 `No such module 'Kingfisher'`를 보고하지만, 실제 xcodebuild 통과로 컴파일 정상 확인
- CLI 로직 검증 출력:
- `KR + visible=true + auth=true => true`
- `KR + visible=true + auth=false => false`
- `US + visible=true + auth=false => true`
- `US + visible=false + auth=true => false`

View File

@@ -0,0 +1,70 @@
# 20260327 마이페이지 본인인증 아이템 국가 조건 적용
## 개요
- `MyPageView`의 카테고리 버튼 중 `본인인증/인증완료` 아이템을 접속국가가 한국(`KR`)인 경우에만 노출되도록 변경한다.
- 기존 인증 플로우(Bootpay 호출, 인증 상태 문구)는 한국 사용자에서만 기존대로 유지한다.
## 요구사항 요약
- 대상 파일: `SodaLive/Sources/MyPage/MyPageView.swift`
- 변경 조건:
- 접속국가 코드가 `KR`(정규화 기준 적용)일 때만 `본인인증/인증완료` 아이템 표시
- 국가코드 미수신(빈값) 시 기존 저장소 관례에 맞춰 한국 정책(`KR`)으로 취급
- `KR`이 아니면 해당 아이템 미표시
## 완료 기준 (Acceptance Criteria)
- [x] AC1: `MyPageView`에서 접속국가 코드 정규화(`trim + uppercased`)가 적용된다.
- [x] AC2: `CategoryButtonsView``본인인증/인증완료` 아이템이 한국 사용자에게만 노출된다.
- [x] AC3: 한국 사용자의 기존 인증 플로우(`isShowAuthView = true`)가 유지된다.
- [x] AC4: 빌드/진단 검증 결과가 문서에 기록된다.
## 구현 체크리스트
- [x] `MyPageView`에서 국가코드 기반 불리언(`isKoreanCountry`) 계산 로직 추가
- [x] `CategoryButtonsView`에 국가코드 조건 전달 파라미터 추가
- [x] 카테고리 그리드의 `본인인증/인증완료` 아이템 KR 조건부 렌더링 적용
- [x] 수정 파일 진단 및 워크스페이스 빌드/테스트 명령 실행
- [x] 검증 결과 문서화
## 검증 계획
- [x] 정적 진단: `lsp_diagnostics("SodaLive/Sources/MyPage/MyPageView.swift")`
- [x] 빌드: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- [x] 빌드(개발): `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-27
- 무엇: 마이페이지 본인인증 아이템 국가 조건 적용 작업 계획 문서 작성
- 왜: 구현 전 변경 범위와 검증 절차를 체크리스트 기반으로 고정하기 위함
- 어떻게: 기존 `docs` 문서 포맷을 기준으로 요구사항/완료기준/검증계획을 정리
- 실행 명령/도구: `read(docs/)`, `apply_patch(문서 생성)`
- 결과: 구현용 계획 문서 초안 생성 완료
- 일시: 2026-03-27
- 무엇: 마이페이지 `본인인증/인증완료` 아이템 KR 조건부 노출 구현 및 검증
- 왜: 비한국 접속국가에서 해당 아이템이 노출되지 않도록 정책을 적용하기 위함
- 어떻게:
- `MyPageView`에서 `countryCode``trim + uppercased`로 정규화하고 `isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"` 계산
- `CategoryButtonsView``isKoreanCountry` 전달 파라미터를 추가하고, `ic_my_auth` 아이템을 `if isKoreanCountry`로 감싸 조건부 렌더링
- 사용자 요청 search-mode에 맞춰 explore/librarian 병렬 탐색 + Grep/ast-grep 직접 탐색 결과를 교차 검증
- 실행 명령/도구:
- Background agents:
- `task(subagent_type="explore", description="Find KR gating patterns")`
- `task(subagent_type="explore", description="Trace MyPage auth item")`
- `task(subagent_type="librarian", description="Find SwiftUI conditional item patterns")`
- `task(subagent_type="librarian", description="Find locale/country code handling examples")`
- Direct search:
- `grep("본인인증|인증완료|ic_my_auth", MyPageView.swift)`
- `ast_grep_search("CategoryButtonItem(icon: \"ic_my_auth\", title: $TITLE) { $$$ }", lang: "swift")`
- `rg` 시도(`command not found: rg`)로 환경 미설치 확인
- 검증:
- `lsp_diagnostics("SodaLive/Sources/MyPage/MyPageView.swift")`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- `SodaLive` / `SodaLive-dev` Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 환경에서 `No such module 'Bootpay'`를 보고했으나, 실제 `xcodebuild` 통과로 컴파일 정상 확인
- 수동 QA는 현재 CLI 환경 한계로 미실행(실기기/시뮬레이터에서 KR/non-KR 노출 확인 필요)

View File

@@ -0,0 +1,68 @@
# 20260327 제외 API 콘텐츠 설정 파라미터 제거
## 개요
- `PATCH /member/content-preference`를 제외한 모든 API 요청에서 `isAdultContentVisible`, `contentType` 파라미터를 제거한다.
- 콘텐츠 설정 동기화 API(`PATCH /member/content-preference`)의 요청/응답 구조와 호출 흐름은 유지한다.
## 요구사항 요약
- 유지 대상 API: `PATCH /member/content-preference`
- 제거 대상: 유지 대상 API를 제외한 나머지 API 요청 파라미터의 `isAdultContentVisible`, `contentType`
## 완료 기준 (Acceptance Criteria)
- [x] AC1: `PATCH /member/content-preference` 외 API 정의에서 `isAdultContentVisible`/`contentType` 요청 파라미터가 제거된다.
- [x] AC2: 제거에 따라 연쇄되는 Repository/Request 모델 시그니처가 정합성 있게 정리된다.
- [x] AC3: `PATCH /member/content-preference` 요청/응답 필드(`isAdultContentVisible`, `contentType`)는 유지된다.
- [x] AC4: 정적 진단/빌드/수동 QA(검색 검증) 결과가 통과 또는 사유와 함께 기록된다.
## 구현 체크리스트
- [x] API 타깃(`HomeApi`, `LiveApi`, `SearchApi`, `ContentApi`, `SeriesApi`, `SeriesMainApi`, `ExplorerApi`) 파라미터 제거
- [x] 연관 Repository 메서드 시그니처 및 호출부 인자 정리
- [x] `PATCH /member/content-preference` 체인(`UserApi`, `UpdateContentPreferenceRequest/Response`, `ContentSettingsViewModel`) 유지 확인
- [x] 진단/빌드/수동 QA 실행
- [x] 검증 기록 문서화
## 검증 계획
- [x] 정적 진단:
- `lsp_diagnostics` on modified Swift files
- [x] 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- [x] 수동 QA:
- `grep` 기반으로 `/member/content-preference` PATCH 외 API 요청 파라미터 잔존 여부 확인
## 검증 기록
- 일시: 2026-03-27
- 무엇: 제외 API 콘텐츠 설정 파라미터 제거 작업 계획 문서 작성
- 왜: 변경 범위와 완료 기준을 선행 고정해 요청사항을 정확히 반영하기 위함
- 어떻게: docs 규칙에 맞춰 완료 기준/체크리스트/검증 계획 수립
- 실행 명령/도구: `apply_patch(문서 생성)`
- 결과: 구현 계획 문서 생성 완료
- 일시: 2026-03-27
- 무엇: `PATCH /member/content-preference` 제외 API의 `isAdultContentVisible`/`contentType` 요청 파라미터 제거
- 왜: 콘텐츠 설정 PATCH API를 제외한 다른 API에서 두 파라미터를 전송하지 않도록 하기 위함
- 어떻게:
- API 타깃(`HomeApi`, `LiveApi`, `SearchApi`, `ContentApi`, `SeriesApi`, `SeriesMainApi`, `ExplorerApi`) case 시그니처와 `task` 파라미터 딕셔너리에서 두 필드를 제거
- 연관 Repository 호출부에서 `UserDefaults` 기반 인자 전달 제거
- `GetRoomListRequest``isAdultContentVisible` 필드 및 `LiveViewModel` 생성 인자 제거
- `PATCH /member/content-preference` 체인(`UserApi.updateContentPreference`, `UpdateContentPreferenceRequest/Response`) 유지 검증
- 실행 명령/도구:
- 정적 진단: `lsp_diagnostics` (수정 파일 전체)
- 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 수동 QA(검색 검증):
- `grep("isAdultContentVisible\\s*:", path="SodaLive/Sources")`
- `grep("contentType\\s*:", path="SodaLive/Sources")`
- `grep("updateContentPreference|member/content-preference", path="SodaLive/Sources/User/UserApi.swift")`
- 결과:
- 빌드: `SodaLive`, `SodaLive-dev` Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트: 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`: SourceKit 환경에서 외부 모듈(`Moya`, `CombineMoya`) 해석 한계로 모듈 미해결 에러가 반환됨
- 수동 QA: `isAdultContentVisible`/`contentType`는 설정 동기화 체인과 응답 모델/화면 상태에만 남고, `/member/content-preference` PATCH 외 API 요청 파라미터에서는 제거됨

View File

@@ -0,0 +1,94 @@
# 20260327 캐릭터 리스트 콘텐츠 설정 이동 안내 표시 개선
## 개요
- 채팅 캐릭터 리스트에서 `isAdultContentVisible == false`로 인해 콘텐츠 보기 설정으로 이동할 때 안내 토스트/팝업이 사용자에게 보이지 않는 문제를 수정한다.
- 이동 시점과 안내 표시 시점을 조정해 사용자가 안내 문구를 실제로 확인할 수 있도록 한다.
## 요구사항 요약
- 대상 흐름: 캐릭터 리스트 상세 진입 가드에서 non-KR + 민감 콘텐츠 OFF 분기
- 문제: 현재는 현재 화면에 토스트를 띄우고 곧바로 화면 전환되어 안내가 체감되지 않음
- 목표: 콘텐츠 보기 설정 화면 전환 후에도 안내 메시지가 사용자에게 명확히 보이도록 처리
## 완료 기준 (Acceptance Criteria)
- [x] AC1: non-KR + 민감 콘텐츠 OFF 분기에서 콘텐츠 설정으로 이동 동작은 유지된다.
- [x] AC2: 안내 메시지가 실제로 보이는 시점으로 표시 로직이 조정된다.
- [x] AC3: KR 인증 분기/기존 인증 플로우에는 영향이 없다.
- [x] AC4: 관련 화면에서 빌드/진단 결과가 문서에 기록된다.
## 구현 체크리스트
- [x] 팝업 렌더링 위치/생명주기 확인
- [x] 기존 이동 + 메시지 설정 순서의 문제 원인 확정
- [x] 최소 수정으로 안내 메시지 표시 시점 조정
- [x] 수정 파일 진단 및 워크스페이스 빌드/테스트 실행
- [x] 검증 결과 문서화
## 검증 계획
- [x] 정적 진단: 수정 파일 `lsp_diagnostics`
- [x] 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-27
- 무엇: 캐릭터 리스트 콘텐츠 설정 이동 시 안내 표시 개선 작업 계획 문서 작성
- 왜: 구현 전 변경 범위/완료 기준/검증 절차를 고정해 요청사항을 정확히 반영하기 위함
- 어떻게: docs 규칙에 맞춰 요구사항/체크리스트/검증계획을 정리
- 실행 명령/도구: `apply_patch(문서 생성)`
- 결과: 구현 계획 문서 생성 완료
- 일시: 2026-03-27
- 무엇: 채팅 캐릭터 리스트의 non-KR + 민감 콘텐츠 OFF 분기에서 콘텐츠 설정 이동 안내 표시 시점 조정
- 왜: 토스트를 먼저 띄우고 즉시 화면 전환하면 사용자가 안내 메시지를 보기 어려워 UX가 손실되기 때문
- 어떻게:
- analyze-mode 요구에 맞춰 병렬 탐색 수행
- explore: `Trace error popup lifecycle`, `Find message-after-navigation patterns`
- direct: `grep/ast-grep/lsp_symbols``AppState.errorMessage`, `isShowErrorPopup`, `.contentViewSettings`, `sodaToast` 렌더링 위치 확인
- `ChatTabView`에서 non-KR 분기를 `if !isKoreanCountry && !UserDefaults.isAdultContentVisible()`로 유지
- 기존의 “토스트 세팅 후 즉시 이동” 대신 `moveToContentSettingsWithGuideToast()`
- 먼저 `.contentViewSettings`로 이동
- `DispatchQueue.main.asyncAfter(0.2)` 후 안내 토스트 표시
- scope 최소화를 위해 요청 대상인 채팅 캐릭터 리스트 경로(`ChatTabView`)만 수정
- 실행 명령/도구:
- `lsp_diagnostics("SodaLive/Sources/Chat/ChatTabView.swift")`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- 두 스킴 Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 환경에서 `No such module 'Bootpay'`를 보고했으나, 실제 빌드 통과로 컴파일 정상 확인
- 수동 QA는 CLI 환경 제약으로 미실행(실기기/시뮬레이터에서 non-KR + 민감 콘텐츠 OFF 시 콘텐츠 설정 화면에서 안내 토스트 노출 확인 필요)
- 일시: 2026-03-27
- 무엇: 재현 보고(토스트 미노출) 기반 2차 수정 — 콘텐츠 설정 화면에서 안내 토스트를 직접 소비하도록 변경
- 왜: 기존 방식은 전환 타이밍/전역 토스트 상태 의존으로 인해 사용자 환경에서 안내가 보이지 않는 케이스가 재현되었기 때문
- 어떻게:
- 원인 확인: `ContentSettingsView`는 로컬 토스트(`viewModel.isShowPopup`)만 표시하고, 캐릭터 리스트 경로는 `AppState` 전역 토스트 상태 타이밍에 의존
- `AppState`에 일회성 전달 상태 추가
- `pendingContentSettingsGuideMessage`
- `setPendingContentSettingsGuideMessage(_:)`
- `consumePendingContentSettingsGuideMessage()`
- `ChatTabView.moveToContentSettingsWithGuideToast()`에서 전역 토스트 토글 대신
- 안내 문구를 pending 상태로 저장
- `.contentViewSettings` 이동만 수행
- `ContentSettingsView.onAppear`에서 pending 문구를 consume하여
- `viewModel.errorMessage` 설정
- `viewModel.isShowPopup = true`로 로컬 토스트 즉시 노출
- analyze-mode 병렬 탐색 결과(`Trace content settings toast suppression`, `Find reliable post-redirect notice patterns`)를 반영해 최소 변경으로 해결
- 실행 명령/도구:
- `lsp_diagnostics("SodaLive/Sources/App/AppState.swift")`
- `lsp_diagnostics("SodaLive/Sources/Chat/ChatTabView.swift")`
- `lsp_diagnostics("SodaLive/Sources/Settings/Content/ContentSettingsView.swift")`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- 두 스킴 Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 환경 한계로 모듈/스코프 미해결 오류를 보고했으나 실제 빌드는 통과
- 수동 QA는 CLI 환경 제약으로 미실행(실기기/시뮬레이터에서 non-KR + 민감 콘텐츠 OFF → 캐릭터 탭 → 콘텐츠 설정 진입 직후 안내 토스트 노출 확인 필요)

View File

@@ -0,0 +1,64 @@
# 20260327 캐릭터 상세 진입 인증 국가 분기 적용
## 개요
- 캐릭터(또는 크리에이터) 터치로 상세 페이지로 이동할 때 수행되는 인증 체크를 접속국가 기준으로 분기한다.
- 한국(`KR`) 사용자는 기존 본인인증 체크를 유지하고, 비한국 사용자는 콘텐츠 보기 설정 경로를 안내하는 기존 정책과 동일하게 맞춘다.
## 요구사항 요약
- 대상: 캐릭터 상세 진입 탭 핸들러의 인증 가드 로직
- 변경 사항:
- `KR`(정규화 기준, 빈값 포함) 사용자: 기존 본인인증 체크 유지
- `non-KR` 사용자: 인증 대신 콘텐츠 보기 설정 유도 정책 적용
## 완료 기준 (Acceptance Criteria)
- [x] AC1: 캐릭터 상세 진입 인증 가드에서 국가코드 정규화(`trim + uppercased`)가 적용된다.
- [x] AC2: 한국 사용자는 기존 본인인증 체크 흐름이 유지된다.
- [x] AC3: 비한국 사용자는 콘텐츠 보기 설정 유도 분기로 동작한다.
- [x] AC4: 기존 네비게이션/팝업 흐름과 충돌 없이 동작한다.
- [x] AC5: 빌드/진단/테스트 시도 결과가 문서에 기록된다.
## 구현 체크리스트
- [x] 캐릭터 상세 진입 인증 체크 위치 식별
- [x] 기존 국가 분기 정책 스니펫 확인 및 재사용 지점 선정
- [x] 탭 핸들러 분기 로직 변경
- [x] 수정 파일 진단 및 워크스페이스 빌드/테스트 실행
- [x] 검증 기록 문서화
## 검증 계획
- [x] 정적 진단: 수정 파일 `lsp_diagnostics`
- [x] 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-27
- 무엇: 캐릭터 상세 진입 인증 국가 분기 적용 작업 계획 문서 작성
- 왜: 구현 전 변경 범위와 완료 기준을 고정해 요청사항을 정확히 반영하기 위함
- 어떻게: docs 규칙에 맞춰 요구사항/완료기준/검증계획을 체크리스트로 구성
- 실행 명령/도구: `apply_patch(문서 생성)`
- 결과: 구현 계획 문서 생성 완료
- 일시: 2026-03-27
- 무엇: 캐릭터 상세 진입 인증 체크를 국가코드 기준(KR/non-KR)으로 분기 적용
- 왜: 기존 “모든 국가 auth 필수” 로직을 정책 변경사항(한국은 본인인증, 비한국은 콘텐츠 보기 설정 유도)에 맞추기 위함
- 어떻게:
- `ChatTabView.handleCharacterSelection(_:)``HomeTabView.handleCharacterSelection(_:)``countryCode` 정규화(`trim + uppercased`) 추가
- `isKoreanCountry`일 때만 기존 `auth == false` 본인인증 팝업(`isShowAuthConfirmView`) 흐름 유지
- `!isKoreanCountry && !UserDefaults.isAdultContentVisible()` 조건에서
- `AppState.shared.errorMessage = I18n.Settings.adultContentEnableGuide`
- `AppState.shared.isShowErrorPopup = true`
- `AppState.shared.setAppStep(step: .contentViewSettings)`
로 유도하고 상세 진입 차단
- 실행 명령/도구:
- 탐색: `task(subagent_type="explore", description="Find character detail auth gate")`, `task(subagent_type="explore", description="Find country branch conventions")`
- 진단: `lsp_diagnostics(ChatTabView.swift)`, `lsp_diagnostics(HomeTabView.swift)`
- 빌드: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 테스트 시도: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- 두 스킴 Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 환경에서 `No such module 'Bootpay'`를 보고하지만 실제 xcodebuild는 통과하여 컴파일 정상 확인
- 수동 QA는 현재 CLI 환경에서 UI 탭/팝업 플로우 실행이 불가하여 미실행(실기기/시뮬레이터에서 KR/non-KR 분기 확인 필요)

View File

@@ -0,0 +1,72 @@
# 20260327 콘텐츠 설정 PATCH 변경 필드 옵셔널 전송
## 개요
- `userApi.updateContentPreference` 요청 시 `contentType`, `isAdultContentVisible`를 항상 같이 보내지 않고, 실제 변경된 필드만 PATCH payload에 포함되도록 수정한다.
- 요청 모델을 optional 파라미터로 변경하고, 기존 UI/동기화 동작은 유지한다.
## 요구사항 요약
- 대상 API: `PATCH /member/content-preference`
- 변경 사항:
- 요청 DTO의 `contentType`, `isAdultContentVisible`를 optional로 전환
- 토글 변경 시에는 `isAdultContentVisible`만 전송
- 콘텐츠 타입 변경 시에는 `contentType`만 전송
## 완료 기준 (Acceptance Criteria)
- [x] AC1: `UpdateContentPreferenceRequest`가 optional 필드를 사용한다.
- [x] AC2: 토글 변경 요청 payload에 `isAdultContentVisible`만 포함된다.
- [x] AC3: 콘텐츠 타입 변경 요청 payload에 `contentType`만 포함된다.
- [x] AC4: 기존 debounce/로딩/에러 처리 흐름이 유지된다.
- [x] AC5: 빌드/진단 검증 결과가 문서에 기록된다.
## 구현 체크리스트
- [x] `UpdateContentPreferenceRequest` optional 필드 전환
- [x] `ContentSettingsViewModel` 요청 생성 로직을 변경 필드 기반으로 분기
- [x] `UserApi`/`UserRepository` 호출부 영향 점검
- [x] 수정 파일 진단 및 워크스페이스 빌드/테스트 실행
- [x] 검증 결과 문서화
## 검증 계획
- [x] 정적 진단:
- `lsp_diagnostics("SodaLive/Sources/Settings/Content/UpdateContentPreferenceRequest.swift")`
- `lsp_diagnostics("SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift")`
- [x] 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-27
- 무엇: 콘텐츠 설정 PATCH 변경 필드 옵셔널 전송 작업 계획 문서 작성
- 왜: 구현 범위와 검증 절차를 선행 고정하여 요청사항을 정확히 반영하기 위함
- 어떻게: 기존 docs 포맷 기준으로 완료 기준/체크리스트/검증 계획 수립
- 실행 명령/도구: `apply_patch(문서 생성)`
- 결과: 구현 계획 문서 생성 완료
- 일시: 2026-03-27
- 무엇: `updateContentPreference`를 변경 필드만 전송하도록 optional request + diff 기반 전송 로직 적용
- 왜: PATCH 호출 시 `contentType`/`isAdultContentVisible`를 항상 함께 보내지 않고 실제 변경 필드만 서버에 전달하기 위함
- 어떻게:
- `UpdateContentPreferenceRequest``Bool?`/`ContentType?`로 변경하고 `isEmpty` 계산 프로퍼티 추가
- `ContentSettingsViewModel``lastSyncedState`를 추가해 이전 동기화 상태 대비 변경 필드를 계산
- `makeUpdateContentPreferenceRequest(from:to:)`에서 변경된 값만 request에 채우고, 빈 요청은 API 호출 생략
- 서버 성공 응답 시 `applyServerState`에서 `lastSyncedState`를 갱신해 후속 diff 기준 일관성 유지
- search-mode 준수를 위해 explore 에이전트 2개 병렬 실행으로 호출 흐름/optional 패턴 교차 확인
- 실행 명령/도구:
- Background agents:
- `task(subagent_type="explore", description="Trace content-preference flow")`
- `task(subagent_type="explore", description="Find optional PATCH patterns")`
- 코드/진단:
- `lsp_diagnostics("SodaLive/Sources/Settings/Content/UpdateContentPreferenceRequest.swift")`
- `lsp_diagnostics("SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift")`
- 빌드/테스트:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- `SodaLive`/`SodaLive-dev` Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 모듈 해석 한계로 다수 에러를 반환했으나, 실제 xcodebuild 통과로 컴파일 정상 확인
- 코드상으로 토글 변경 시 `isAdultContentVisible`만, 타입 변경 시 `contentType`만 request에 포함되도록 반영 완료

View File

@@ -0,0 +1,66 @@
# 20260328 라이브 19금 설정 이동 후 토스트 표시
## 개요
- 라이브 아이템(19금) 터치 시 `민감한 콘텐츠 보기`가 꺼져 있으면, 현재 화면에서 토스트를 먼저 띄우고 즉시 설정 화면으로 이동하여 메시지 확인이 어려운 문제를 수정한다.
- 채팅 캐릭터 상세 진입과 동일하게, 콘텐츠 보기 설정 화면으로 먼저 이동한 뒤 안내 토스트가 보이도록 흐름을 통일한다.
## 완료 기준 (Acceptance Criteria)
- [x] AC1: 라이브 19금 아이템 터치 + 민감한 콘텐츠 OFF 조건에서 `.contentViewSettings` 이동이 정상 동작한다.
- QA: 실기기/시뮬레이터에서 해당 조건 재현 후 화면 전환 확인.
- [x] AC2: 설정 화면 진입 직후 `I18n.Settings.adultContentEnableGuide` 토스트가 표시된다.
- QA: 설정 화면에서 토스트 노출 여부 확인.
- [x] AC3: KR 본인인증 분기(`isKoreanCountry && auth == false`) 동작은 기존과 동일하다.
- QA: KR + 미인증 계정으로 터치 시 인증 다이얼로그 노출 확인.
- [x] AC4: 성인 방송이 아니거나 민감한 콘텐츠 ON 상태에서는 기존 라이브 상세 진입 동작을 유지한다.
- QA: non-adult / adult+ON 각각 상세 진입 확인.
## 구현 체크리스트
- [x] 라이브 진입 성인 가드 구현 위치(`HomeView.handleLiveNowItemTap`) 수정
- [x] 기존 패턴과 동일하게 `pendingContentSettingsGuideMessage` 기반으로 토스트 전달
- [x] 요청 범위 파일(`HomeTabView`, `LiveView`) 연계 동작 영향 점검
- [x] 정적 진단/빌드/테스트 실행
- [x] 문서 체크박스 및 검증 기록 업데이트
## 검증 계획
- [x] `lsp_diagnostics`:
- `SodaLive/Sources/Main/Home/HomeView.swift`
- (영향 점검) `SodaLive/Sources/Home/HomeTabView.swift`
- (영향 점검) `SodaLive/Sources/Live/LiveView.swift`
- [x] 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-28
- 무엇: 라이브 19금 콘텐츠 설정 이동 후 토스트 표시 개선 작업 계획 수립
- 왜: 요청사항의 완료 기준/검증 절차를 고정해 정확히 동일 동작으로 수정하기 위함
- 어떻게: 기존 패턴(`ChatTabView``ContentSettingsView.onAppear`) 탐색 결과를 바탕으로 최소 변경 계획 문서화
- 실행 명령/도구: `apply_patch(문서 생성)`
- 결과: 계획 문서 생성 완료
- 일시: 2026-03-28
- 무엇: 라이브 19금 진입 차단 시 토스트 표시 시점을 설정 화면 진입 후로 변경
- 왜: 기존에는 메시지 표시와 화면 이동이 동시에 발생해 안내 문구 확인이 어려웠기 때문
- 어떻게:
- `HomeView.handleLiveNowItemTap`의 성인 콘텐츠 OFF 분기에서 전역 에러 팝업 즉시 표시를 제거
- `moveToContentSettingsWithGuideToast()`를 추가해
- `AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide)`
- `AppState.shared.setAppStep(step: .contentViewSettings)`
순서로 처리
- `ContentSettingsView.onAppear`의 기존 pending 메시지 consume 패턴을 그대로 재사용해 설정 화면에서 토스트 표시
- 실행 명령/도구:
- `lsp_diagnostics("SodaLive/Sources/Main/Home/HomeView.swift")`
- `lsp_diagnostics("SodaLive/Sources/Home/HomeTabView.swift")`
- `lsp_diagnostics("SodaLive/Sources/Live/LiveView.swift")`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- 두 스킴 Debug 빌드 모두 `** BUILD SUCCEEDED **`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 환경 한계로 외부 모듈 미해결(`Firebase`, `Bootpay`, `RefreshableScrollView`) 오류를 보고했으나 실제 빌드는 통과
- 수동 QA는 CLI 환경 제약으로 미실행(실기기/시뮬레이터에서 라이브 19금 + 민감 콘텐츠 OFF 시 설정 화면 진입 후 토스트 노출 확인 필요)

View File

@@ -0,0 +1,38 @@
# 20260328 방장 캡쳐/녹화 허용
## 작업 체크리스트
- [x] `LiveRoomViewV2` 캡쳐/녹화 보호 적용 지점 재확인
- [x] 현재 사용자 방장 여부 계산 프로퍼티 추가
- [x] 방장일 때 `ScreenCaptureSecureContainer` 미적용 분기 추가
- [x] 방장일 때 캡쳐 감지 오버레이/강제 음소거 로직 비활성화
- [x] LSP/빌드/테스트 검증 수행
- [x] 검증 결과 기록
## 수용 기준 (Acceptance Criteria)
- [x] 방장(`creatorId == currentUserId`)은 라이브룸 화면에서 스크린샷/화면 녹화가 가능하다. (코드 경로 기준)
- [x] 비방장(게스트/리스너/스피커)은 기존 캡쳐/녹화 보호가 유지된다.
- [x] 캡쳐 감지 시 비방장에게만 검정 오버레이/강제 음소거가 적용된다.
- [x] 변경 파일 LSP 진단 오류가 없다.
- [x] `SodaLive`, `SodaLive-dev` Debug build가 성공한다.
## 검증 기록
### 1차 검증 (2026-03-28)
- 무엇/왜/어떻게:
- 무엇: 방장만 캡쳐/녹화 보호를 우회하도록 조건 분기를 적용.
- 왜: 요청사항(방장 캡쳐·녹화 허용) 충족과 비방장 보호 유지.
- 어떻게: `ScreenCaptureSecureContainer`를 런타임에서 secure on/off 가능한 구조로 확장하고, 방장 여부에 따라 캡쳐 보호 동기화를 분기했다.
- 실행 명령:
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift, severity: all)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `grep("\\*\\* BUILD SUCCEEDED \\*\\*", /Users/klaus/.local/share/opencode/tool-output/tool_d340d3dc1001ZldmuCAgiYN1ly)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- `grep("shouldEnforceScreenCaptureProtection|syncScreenCaptureProtectionState", LiveRoomViewV2.swift)`
- 결과:
- `lsp_diagnostics`: `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA(코드 경로):
- 방장/비방장 분기 확인: `ScreenCaptureSecureContainer(isSecureModeEnabled: shouldEnforceScreenCaptureProtection)`
- 방장 동기화 확인: `syncScreenCaptureProtectionState()`에서 방장인 경우 `isScreenCaptureProtected = false` + `releaseForcedCaptureMute()`
- 비방장 보호 유지 확인: 비방장인 경우 `applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)`
- 제한사항: 현재 CLI/헤드리스 환경에서 실제 기기 스크린샷/화면녹화 버튼 조작 E2E는 불가하여, 실기기 최종 확인이 추가로 필요.

View File

@@ -0,0 +1,37 @@
# 20260330 라이브룸 스탭 캡쳐/녹화 권한 확장
## 작업 체크리스트
- [x] `LiveRoomViewV2`의 기존 캡쳐/녹화 권한 분기(방장 전용) 확인
- [x] 스탭(`managerList`/`MANAGER`) 판별 방식 확인 및 적용 기준 확정
- [x] 캡쳐/녹화 허용 대상을 방장+스탭으로 확장
- [x] LSP/빌드/테스트/수동 QA 검증 수행
- [x] 검증 결과 기록
## 수용 기준 (Acceptance Criteria)
- [x] 방장 또는 스탭인 경우 라이브룸 화면에서 캡쳐/녹화 보호가 적용되지 않는다.
- [x] 방장/스탭이 아닌 참여자는 기존 캡쳐/녹화 보호가 유지된다.
- [x] 캡쳐 감지 오버레이 및 강제 음소거 로직은 방장/스탭이 아닌 참여자에게만 동작한다.
- [x] 변경 파일 LSP 진단 오류가 없다.
- [x] Debug 빌드가 성공한다 (`SodaLive`, `SodaLive-dev`).
## 검증 기록
### 1차 검증 (2026-03-30)
- 무엇/왜/어떻게:
- 무엇: 라이브룸 캡쳐/녹화 보호 예외 대상을 `방장`에서 `방장 + 스탭`으로 확장하고, `managerList` 변경 시 보호 상태를 즉시 재동기화하도록 변경.
- 왜: 스탭 권한이 입장 시 고정이 아니라 방송 중 방장에 의해 동적으로 부여/해제되므로, 중간 권한 변경 시에도 캡쳐/녹화 허용 여부가 즉시 반영되어야 함.
- 어떻게: `LiveRoomViewV2``isCurrentUserStaff` 계산 프로퍼티를 추가하고 `shouldEnforceScreenCaptureProtection``!(isCurrentUserHost || isCurrentUserStaff)`로 변경. 또한 `.onChange(of: viewModel.liveRoomInfo?.managerList)`에서 `syncScreenCaptureProtectionState()`를 호출해 동적 권한 변경을 반영.
- 실행 명령:
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift, severity: all)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `grep("\*\* BUILD SUCCEEDED \*\*", include: tool_d3db4bd4c001vPzVKsa2VZSVFE)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- `grep("isCurrentUserStaff|shouldEnforceScreenCaptureProtection|onChange\\(of: viewModel.liveRoomInfo\\?\\.managerList\\)", LiveRoomViewV2.swift)`
- 결과:
- `lsp_diagnostics`: `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA(코드 경로):
- 권한 분기 확인: `shouldEnforceScreenCaptureProtection = !(isCurrentUserHost || isCurrentUserStaff)`
- 동적 권한 반영 확인: `.onChange(of: viewModel.liveRoomInfo?.managerList) { syncScreenCaptureProtectionState() }`
- 보호/해제 동작 확인: `syncScreenCaptureProtectionState()`에서 보호 비대상(방장/스탭)일 때 `isScreenCaptureProtected = false``releaseForcedCaptureMute()` 호출
- 제한사항: CLI 환경 특성상 실기기에서 실제 스크린샷/화면녹화 버튼 조작 E2E는 수행하지 못했으며, 최종 사용자 시나리오는 실기기 확인이 필요.

View File

@@ -0,0 +1,31 @@
# 20260330 라이브룸 스탭 해제 갱신 수정
## 작업 개요
- 라이브 진행 중 스탭 지정/해제 시 `LiveRoomProfilesDialogView`의 스탭 표시가 실시간으로 정확히 갱신되도록 원인 분석 및 수정한다.
## 구현 체크리스트
- [x] 관련 코드 경로 병렬 탐색(Explore + 직접 검색)으로 원인 확정
- [x] 스탭 해제 동작 시 서버/클라이언트 상태 갱신 누락 수정
- [x] `LiveRoomProfilesDialogView`에 전달되는 `roomInfo` 재조회 타이밍 보정
- [x] 변경 파일 진단 및 빌드 검증 수행
- [x] 검증 기록 누적
## 검증 기록
- 무엇: 스탭 해제 시점에 방장 클라이언트가 너무 이른 시점에만 `getRoomInfo()`를 호출해 `managerList`가 stale 상태로 남는 문제를 수정.
- 왜: `LiveRoomProfilesDialogView`는 전달받은 `roomInfo.managerList`를 표시하므로, 해제 완료 이후의 최신 `roomInfo` 재조회 트리거가 필요.
- 어떻게:
- `LiveRoomViewModel.changeListener(peerId:isFromManager:)`에서 스탭 해제 시 즉시 `setManagerMessage()`를 보내던 흐름을 제거하고, 해제 안내 메시지 전송 후 지연 재조회(`DispatchQueue.main.asyncAfter`)를 추가.
- `LiveRoomViewModel.setListener()`에서 현재 사용자가 해제 대상 스탭이었던 경우(`wasManager`)에 `setManagerMessage()`를 전파해, 실제 해제 완료 이후 전체 클라이언트가 `getRoomInfo()`를 재호출하도록 보강.
- 실행 명령 및 결과:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build``** BUILD SUCCEEDED **`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build``** BUILD SUCCEEDED **`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test``Scheme SodaLive is not currently configured for the test action.`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test``Scheme SodaLive-dev is not currently configured for the test action.`
- `lsp_diagnostics(LiveRoomViewModel.swift)``No such module 'Moya'` (로컬 SourceKit 모듈 해석 환경 이슈로 확인됨)
- 수동 QA 시나리오(디바이스/시뮬레이터):
1. 방장이 스피커/리스너를 스탭으로 지정한다.
2. `LiveRoomProfilesDialogView`에서 스탭 섹션에 즉시 반영되는지 확인한다.
3. 동일 사용자를 스탭 해제한다.
4. 다이얼로그를 닫지 않은 상태에서도 스탭 섹션에서 제거되는지 확인한다.

View File

@@ -0,0 +1,48 @@
# 20260330 라이브 캡쳐/녹화 가능 여부 설정 추가
## 작업 체크리스트
- [x] 라이브 정보 응답 모델에 `isCaptureRecordingAvailable` 필드 추가 및 매핑 확인
- [x] `LiveRoomViewV2` 캡쳐/녹화 보호 조건에 라이브 설정값 반영
- [x] 캡쳐/녹화 불가 라이브에서 방장/스탭 예외 허용 유지
- [x] 라이브 생성 경로에만 설정값 전송되도록 반영
- [x] 라이브 수정(편집) 경로에서 해당 설정 변경 불가 상태 유지 확인
- [x] 진단/빌드/테스트/수동 QA 수행
## 수용 기준 (Acceptance Criteria)
- [x] `GetRoomInfoResponse`(또는 동등 라이브 정보 모델)에 `isCaptureRecordingAvailable`가 존재한다.
- [x] 라이브 설정값이 `true`면 일반 참여자도 캡쳐/녹화 보호가 비활성화된다.
- [x] 라이브 설정값이 `false`면 일반 참여자는 기존 캡쳐/녹화 보호가 유지된다.
- [x] 라이브 설정값이 `false`여도 방장/스탭은 캡쳐/녹화 보호 대상이 아니다.
- [x] 설정값은 라이브 생성 요청에서만 설정 가능하고, 라이브 수정 요청에서는 변경되지 않는다.
- [x] 변경 파일 `lsp_diagnostics`를 수행했고 `SodaLive`/`SodaLive-dev` Debug build가 성공한다.
## 검증 기록
### 1차 검증 (2026-03-30)
- 무엇/왜/어떻게:
- 무엇: 라이브 정보/생성 요청에 `isCaptureRecordingAvailable`를 추가하고, `LiveRoomViewV2`의 캡쳐 보호 조건을 라이브 설정값 + 방장/스탭 예외로 갱신.
- 왜: 캡쳐/녹화 가능 여부를 라이브 생성 시점에만 제어하면서, 비허용 라이브에서도 운영 권한(방장/스탭) 예외를 유지하기 위해.
- 어떻게: 모델(`GetRoomInfoResponse`, `CreateLiveRoomRequest`, `GetRecentRoomInfoResponse`), 생성 UI/ViewModel(`LiveRoomCreateView`, `LiveRoomCreateViewModel`), 보호 로직(`LiveRoomViewV2`)을 최소 수정으로 연결.
- 실행 명령:
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift, severity: all)`
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift, severity: all)`
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/Create/CreateLiveRoomRequest.swift, severity: all)`
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/Create/GetRecentRoomInfoResponse.swift, severity: all)`
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/Create/LiveRoomCreateViewModel.swift, severity: all)`
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/Create/LiveRoomCreateView.swift, severity: all)`
- `lsp_diagnostics(filePath: SodaLive/Sources/I18n/I18n.swift, severity: all)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- `grep("isCaptureRecordingAvailable", include: *.swift, path: SodaLive/Sources/Live/Room)`
- `grep("captureRecordingSetting|captureRecordingAllowed|captureRecordingNotAllowed", include: *.swift, path: SodaLive/Sources)`
- `grep("isCaptureRecordingAvailable", include: *.swift, path: SodaLive/Sources/Live/Room/Edit)`
- 결과:
- `lsp_diagnostics`:
- `LiveRoomViewV2.swift`, `GetRecentRoomInfoResponse.swift`, `I18n.swift``No diagnostics found`
- 일부 파일(`CreateLiveRoomRequest.swift`, `LiveRoomCreateViewModel.swift`, `LiveRoomCreateView.swift`, `GetRoomInfoResponse.swift`)은 SourceKit 모듈/심볼 해석 한계(`No such module`, `Cannot find type ... in scope`)가 보고됨
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA(코드 경로):
- 생성 UI에 캡쳐/녹화 허용 토글 추가 확인 (`LiveRoomCreateView`)
- 생성 요청에만 `isCaptureRecordingAvailable` 전송 확인 (`CreateLiveRoomRequest`, `LiveRoomCreateViewModel`)
- 편집 경로에 해당 필드 미존재 확인 (`Live/Room/Edit` grep 결과 없음)
- 라이브룸 보호 분기 확인: `isCaptureRecordingAvailable == true`면 보호 비활성화, `false`면 방장/스탭 예외 외 참여자 보호 유지 (`LiveRoomViewV2`)