3 Commits

44 changed files with 1373 additions and 537 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() {
@@ -180,6 +181,16 @@ class AppState: ObservableObject {
pendingCommunityCommentCreatorId = 0 pendingCommunityCommentCreatorId = 0
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() {

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,8 +56,20 @@ 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)

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) {
SelectButtonView(title: I18n.CreateContent.allAges, isChecked: !viewModel.isAdult) { HStack(spacing: 13.3) {
if viewModel.isAdult { SelectButtonView(title: I18n.CreateContent.allAges, isChecked: !viewModel.isAdult) {
viewModel.isAdult = false if viewModel.isAdult {
} viewModel.isAdult = false
} }
}
SelectButtonView(title: I18n.CreateContent.over19, isChecked: viewModel.isAdult) {
if !viewModel.isAdult { SelectButtonView(title: I18n.CreateContent.over19, isChecked: viewModel.isAdult) {
viewModel.isAdult = true if !viewModel.isAdult {
viewModel.isAdult = true
}
} }
} }
Text("성인콘텐츠를 전체관람가로 등록할 시 발생하는 법적 책임은 회사와 상관없이 콘텐츠를 등록한 본인에게 있습니다.\n콘텐츠 내용은 물론 제목도 19금 여부를 체크해 주시기 바랍니다.")
.appFont(size: 13.3, weight: .medium)
.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(

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

@@ -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,7 @@ 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) { 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

@@ -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)
} }
} }
@@ -653,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
} }
@@ -697,6 +697,16 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
} }
} }
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() { func toggleMute() {
setMute(!isMute) setMute(!isMute)
} }

View File

@@ -472,11 +472,29 @@ 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
AppState.shared.errorMessage = I18n.Settings.adultContentEnableGuide
AppState.shared.isShowErrorPopup = true
AppState.shared.setAppStep(step: .contentViewSettings)
return
}
} }
openLiveDetail(roomId: roomId) openLiveDetail(roomId: roomId)
} }

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) {
DetailNavigationBar(title: "콘텐츠 보기 설정") { VStack(spacing: 0) {
if AppState.shared.isRestartApp { DetailNavigationBar(title: "콘텐츠 보기 설정") {
AppState.shared.setAppStep(step: .splash) if AppState.shared.isRestartApp {
} else { AppState.shared.setAppStep(step: .splash)
AppState.shared.back() } else {
} 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 { ScrollView(.vertical) {
Rectangle() VStack(spacing: 0) {
.frame(height: 1)
.foregroundColor(Color.gray90.opacity(0.3))
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 {
adultContentPreference = .ALL @Published var isLoading = false
UserDefaults.set(ContentType.ALL.rawValue, forKey: .contentPreference) @Published var errorMessage = ""
@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
@@ -77,6 +78,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"
@@ -142,6 +146,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
@@ -182,6 +189,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

@@ -52,6 +52,10 @@ final class UserRepository {
func getMemberInfo() -> AnyPublisher<Response, MoyaError> { func getMemberInfo() -> AnyPublisher<Response, MoyaError> {
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,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에 포함되도록 반영 완료