diff --git a/SodaLive/Sources/App/AppState.swift b/SodaLive/Sources/App/AppState.swift index be0a21d..b4bdb75 100644 --- a/SodaLive/Sources/App/AppState.swift +++ b/SodaLive/Sources/App/AppState.swift @@ -74,6 +74,7 @@ class AppState: ObservableObject { @Published var isShowErrorPopup = false @Published var errorMessage = "" + @Published private var pendingContentSettingsGuideMessage: String? = nil @Published var liveDetailSheet: LiveDetailSheetState? = nil private func syncStepWithNavigationPath() { @@ -180,6 +181,16 @@ class AppState: ObservableObject { pendingCommunityCommentCreatorId = 0 pendingCommunityCommentPostId = 0 } + + func setPendingContentSettingsGuideMessage(_ message: String) { + pendingContentSettingsGuideMessage = message + } + + func consumePendingContentSettingsGuideMessage() -> String? { + let message = pendingContentSettingsGuideMessage + pendingContentSettingsGuideMessage = nil + return message + } // 언어 적용 직후 앱을 소프트 재시작(스플래시 -> 메인)하여 전역 UI를 새로고침 func softRestart() { diff --git a/SodaLive/Sources/App/AppViewModel.swift b/SodaLive/Sources/App/AppViewModel.swift index df6b5dd..18d82e9 100644 --- a/SodaLive/Sources/App/AppViewModel.swift +++ b/SodaLive/Sources/App/AppViewModel.swift @@ -75,6 +75,9 @@ final class AppViewModel: ObservableObject { UserDefaults.set(data.isAuth, forKey: .auth) UserDefaults.set(data.role.rawValue, forKey: .role) 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 { AppState.shared.isShowNotificationSettingsDialog = true } diff --git a/SodaLive/Sources/Chat/ChatTabView.swift b/SodaLive/Sources/Chat/ChatTabView.swift index 4cb6b49..c44de84 100644 --- a/SodaLive/Sources/Chat/ChatTabView.swift +++ b/SodaLive/Sources/Chat/ChatTabView.swift @@ -41,7 +41,14 @@ struct ChatTabView: View { AppState.shared.setAppStep(step: .login) 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 = { AppState.shared .setAppStep(step: .characterDetail(characterId: characterId)) @@ -49,8 +56,20 @@ struct ChatTabView: View { isShowAuthConfirmView = true return } + + if !UserDefaults.isAdultContentVisible() { + pendingAction = nil + moveToContentSettingsWithGuideToast() + return + } + AppState.shared.setAppStep(step: .characterDetail(characterId: characterId)) } + + private func moveToContentSettingsWithGuideToast() { + AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide) + AppState.shared.setAppStep(step: .contentViewSettings) + } private func handleCharacterSelection() { let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/SodaLive/Sources/Extensions/UserDefaultsExtension.swift b/SodaLive/Sources/Extensions/UserDefaultsExtension.swift index a23c0ec..10cc892 100644 --- a/SodaLive/Sources/Extensions/UserDefaultsExtension.swift +++ b/SodaLive/Sources/Extensions/UserDefaultsExtension.swift @@ -25,6 +25,7 @@ enum UserDefaultsKey: String, CaseIterable { case notShowingEventPopupId case isAdultContentVisible case contentPreference + case countryCode case isAuditionNotification case searchChannel case marketingPid @@ -73,7 +74,7 @@ extension UserDefaults { static func isAdultContentVisible() -> Bool { 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() { diff --git a/SodaLive/Sources/Home/HomeTabView.swift b/SodaLive/Sources/Home/HomeTabView.swift index 2dbea6a..989d552 100644 --- a/SodaLive/Sources/Home/HomeTabView.swift +++ b/SodaLive/Sources/Home/HomeTabView.swift @@ -44,7 +44,14 @@ struct HomeTabView: View { AppState.shared.setAppStep(step: .login) 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 = { AppState.shared .setAppStep(step: .characterDetail(characterId: characterId)) @@ -52,6 +59,15 @@ struct HomeTabView: View { isShowAuthConfirmView = true 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)) } diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 345adf0..5e29764 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -408,6 +408,30 @@ enum I18n { // 알림 다이얼로그 타이틀 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 { pick( diff --git a/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift b/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift index 44868a7..2ac9751 100644 --- a/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift +++ b/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift @@ -169,11 +169,29 @@ struct LiveNowAllView: View { AppState.shared.setAppStep(step: .login) return } - if isAdult && auth == false { - pendingAction = { openLiveDetail(roomId: roomId) } - isShowAuthConfirmView = true - return + + if isAdult { + let normalizedCountryCode = UserDefaults + .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) } diff --git a/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift index 256ecbf..9832639 100644 --- a/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift +++ b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift @@ -64,7 +64,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject { @Published var isShowUserProfilePopup = false @Published var changeIsAdult = false { didSet { - if changeIsAdult && !UserDefaults.bool(forKey: .auth) { + if changeIsAdult && requiresAdultAuthenticationByCountry() { agora.speakerMute(true) } } @@ -653,7 +653,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject { getTotalDonationCan() getTotalHeartCount() - if data.isAdult && !UserDefaults.bool(forKey: .auth) { + if data.isAdult && requiresAdultAuthenticationByCountry() { 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() { setMute(!isMute) } diff --git a/SodaLive/Sources/Main/Home/HomeView.swift b/SodaLive/Sources/Main/Home/HomeView.swift index 5f71a48..f665f51 100644 --- a/SodaLive/Sources/Main/Home/HomeView.swift +++ b/SodaLive/Sources/Main/Home/HomeView.swift @@ -472,11 +472,29 @@ struct HomeView: View { AppState.shared.setAppStep(step: .login) return } - if isAdult && auth == false { - pendingAction = { openLiveDetail(roomId: roomId) } - isShowAuthConfirmView = true - return + + if isAdult { + let normalizedCountryCode = UserDefaults + .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) } diff --git a/SodaLive/Sources/Main/Home/HomeViewModel.swift b/SodaLive/Sources/Main/Home/HomeViewModel.swift index e645952..a080c5a 100644 --- a/SodaLive/Sources/Main/Home/HomeViewModel.swift +++ b/SodaLive/Sources/Main/Home/HomeViewModel.swift @@ -59,6 +59,9 @@ final class HomeViewModel: ObservableObject { UserDefaults.set(data.isAuth, forKey: .auth) UserDefaults.set(data.role.rawValue, forKey: .role) 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 { AppState.shared.isShowNotificationSettingsDialog = true } diff --git a/SodaLive/Sources/MyPage/MyPageView.swift b/SodaLive/Sources/MyPage/MyPageView.swift index 954db17..3ed20c8 100644 --- a/SodaLive/Sources/MyPage/MyPageView.swift +++ b/SodaLive/Sources/MyPage/MyPageView.swift @@ -21,6 +21,12 @@ struct MyPageView: View { @AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token) var body: some View { + let normalizedCountryCode = UserDefaults + .string(forKey: .countryCode) + .trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased() + let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR" + BaseView(isLoading: $viewModel.isLoading) { if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && viewModel.isShowAuthView { @@ -114,6 +120,7 @@ struct MyPageView: View { CategoryButtonsView( isShowAuthView: $viewModel.isShowAuthView, isAuthenticated: viewModel.isAuth, + isKoreanCountry: isKoreanCountry, showMessage: { viewModel.errorMessage = $0 viewModel.isShowPopup = true @@ -381,6 +388,7 @@ struct CategoryButtonsView: View { @Binding var isShowAuthView: Bool let isAuthenticated: Bool + let isKoreanCountry: Bool let showMessage: (String) -> Void let refresh: () -> Void @@ -420,12 +428,14 @@ struct CategoryButtonsView: View { AppState.shared.setAppStep(step: .serviceCenter) } - CategoryButtonItem( - icon: "ic_my_auth", - title: isAuthenticated ? "인증완료" : "본인인증" - ) { - if !isAuthenticated { - isShowAuthView = true + if isKoreanCountry { + CategoryButtonItem( + icon: "ic_my_auth", + title: isAuthenticated ? "인증완료" : "본인인증" + ) { + if !isAuthenticated { + isShowAuthView = true + } } } } diff --git a/SodaLive/Sources/Settings/Content/ContentSettingsView.swift b/SodaLive/Sources/Settings/Content/ContentSettingsView.swift index 5abcefe..03f55dc 100644 --- a/SodaLive/Sources/Settings/Content/ContentSettingsView.swift +++ b/SodaLive/Sources/Settings/Content/ContentSettingsView.swift @@ -10,113 +10,138 @@ import SwiftUI struct ContentSettingsView: View { @StateObject var viewModel = ContentSettingsViewModel() + @ObservedObject private var appState = AppState.shared var body: some View { - ZStack { - Color.black.ignoresSafeArea() - - VStack(spacing: 0) { - DetailNavigationBar(title: "콘텐츠 보기 설정") { - if AppState.shared.isRestartApp { - AppState.shared.setAppStep(step: .splash) - } 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() - } + BaseView(isLoading: $viewModel.isLoading) { + ZStack { + Color.black.ignoresSafeArea() + + VStack(spacing: 0) { + DetailNavigationBar(title: "콘텐츠 보기 설정") { + if AppState.shared.isRestartApp { + AppState.shared.setAppStep(step: .splash) + } else { + AppState.shared.back() } - .frame(height: 50) - - if viewModel.isAdultContentVisible { - Rectangle() - .frame(height: 1) - .foregroundColor(Color.gray90.opacity(0.3)) - + } + + ScrollView(.vertical) { + VStack(spacing: 0) { HStack(spacing: 0) { - HStack(spacing: 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 - } - + Text("민감한 콘텐츠 보기") + .appFont(size: 15, weight: .bold) + .foregroundColor(Color(hex: "eeeeee")) + Spacer() - - HStack(spacing: 13.3) { - Image( - viewModel.adultContentPreference == .MALE ? - "btn_radio_select_selected" : - "btn_radio_select_normal" - ) + + Image(viewModel.isAdultContentVisible ? "btn_toggle_on_big" : "btn_toggle_off_big") .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(width: 44, height: 27) + .onTapGesture { + viewModel.handleAdultContentToggleTap() + } } .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 } } } diff --git a/SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift b/SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift index 765e931..c2e9749 100644 --- a/SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift +++ b/SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift @@ -6,28 +6,183 @@ // import Foundation +import Combine final class ContentSettingsViewModel: ObservableObject { - @Published var isAdultContentVisible = UserDefaults.isAdultContentVisible() { - didSet { - if oldValue != isAdultContentVisible { - UserDefaults.set(isAdultContentVisible, forKey: .isAdultContentVisible) - AppState.shared.isRestartApp = true - - if !isAdultContentVisible { - adultContentPreference = .ALL - UserDefaults.set(ContentType.ALL.rawValue, forKey: .contentPreference) + private let userRepository = UserRepository() + private var subscription = Set() + private let contentPreferenceSubject = PassthroughSubject() + private var latestRequestToken = UUID() + private var isApplyingServerState = false + private var lastSyncedState: ContentPreferenceState + + @Published var isLoading = false + @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.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 { - didSet { - if oldValue != adultContentPreference { - UserDefaults.set(adultContentPreference.rawValue, forKey: .contentPreference) - AppState.shared.isRestartApp = true - } - } + + func confirmAdultContentAgeCheck() { + isShowAdultContentAgeCheckDialog = false + isAdultContentVisible = true + } + + func cancelAdultContentAgeCheck() { + isShowAdultContentAgeCheckDialog = false } } + +private struct ContentPreferenceState: Equatable { + let isAdultContentVisible: Bool + let contentType: ContentType +} diff --git a/SodaLive/Sources/Settings/Content/UpdateContentPreferenceRequest.swift b/SodaLive/Sources/Settings/Content/UpdateContentPreferenceRequest.swift new file mode 100644 index 0000000..f7a1141 --- /dev/null +++ b/SodaLive/Sources/Settings/Content/UpdateContentPreferenceRequest.swift @@ -0,0 +1,10 @@ +import Foundation + +struct UpdateContentPreferenceRequest: Encodable { + let isAdultContentVisible: Bool? + let contentType: ContentType? + + var isEmpty: Bool { + return isAdultContentVisible == nil && contentType == nil + } +} diff --git a/SodaLive/Sources/Settings/Content/UpdateContentPreferenceResponse.swift b/SodaLive/Sources/Settings/Content/UpdateContentPreferenceResponse.swift new file mode 100644 index 0000000..cf8b7a2 --- /dev/null +++ b/SodaLive/Sources/Settings/Content/UpdateContentPreferenceResponse.swift @@ -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 + } +} diff --git a/SodaLive/Sources/Settings/Notification/GetMemberInfoResponse.swift b/SodaLive/Sources/Settings/Notification/GetMemberInfoResponse.swift index 2ff895e..a4eb428 100644 --- a/SodaLive/Sources/Settings/Notification/GetMemberInfoResponse.swift +++ b/SodaLive/Sources/Settings/Notification/GetMemberInfoResponse.swift @@ -23,4 +23,55 @@ struct GetMemberInfoResponse: Decodable { let followingChannelLiveNotice: Bool? let followingChannelUploadContentNotice: 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" + } } diff --git a/SodaLive/Sources/Settings/SettingsView.swift b/SodaLive/Sources/Settings/SettingsView.swift index fc38535..ad1ee3a 100644 --- a/SodaLive/Sources/Settings/SettingsView.swift +++ b/SodaLive/Sources/Settings/SettingsView.swift @@ -16,6 +16,12 @@ struct SettingsView: View { var body: some View { 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) { GeometryReader { geo in @@ -65,7 +71,7 @@ struct SettingsView: View { AppState.shared.setAppStep(step: .languageSettings) } - if UserDefaults.bool(forKey: .auth) { + if isAuth || isNonKoreanCountry { Rectangle() .frame(width: cardWidth - 26.7, height: 0.3) .foregroundColor(Color.gray90) diff --git a/SodaLive/Sources/User/UserApi.swift b/SodaLive/Sources/User/UserApi.swift index eb41365..2405887 100644 --- a/SodaLive/Sources/User/UserApi.swift +++ b/SodaLive/Sources/User/UserApi.swift @@ -19,6 +19,7 @@ enum UserApi { case searchUser(nickname: String) case getMypage case getMemberInfo + case updateContentPreference(request: UpdateContentPreferenceRequest) case notification(request: UpdateNotificationSettingRequest) case logout case logoutAllDevice @@ -77,6 +78,9 @@ extension UserApi: TargetType { case .getMemberInfo: return "/member/info" + + case .updateContentPreference: + return "/member/content-preference" case .notification: return "/member/notification" @@ -142,6 +146,9 @@ extension UserApi: TargetType { case .searchUser, .getMypage, .getMemberInfo, .getMyProfile, .getChangeNicknamePrice, .checkNickname, .getBlockedMemberList, .getBlockedMemberIdList, .getMemberProfile: return .get + + case .updateContentPreference: + return .patch case .updatePushToken, .profileUpdate, .changeNickname, .updateIdfa, .updateMarketingInfo: return .put @@ -182,6 +189,9 @@ extension UserApi: TargetType { case .notification(let request): return .requestJSONEncodable(request) + + case .updateContentPreference(let request): + return .requestJSONEncodable(request) case .signOut(let request): return .requestJSONEncodable(request) diff --git a/SodaLive/Sources/User/UserRepository.swift b/SodaLive/Sources/User/UserRepository.swift index c7c584f..39e28ab 100644 --- a/SodaLive/Sources/User/UserRepository.swift +++ b/SodaLive/Sources/User/UserRepository.swift @@ -52,6 +52,10 @@ final class UserRepository { func getMemberInfo() -> AnyPublisher { return api.requestPublisher(.getMemberInfo) } + + func updateContentPreference(request: UpdateContentPreferenceRequest) -> AnyPublisher { + return api.requestPublisher(.updateContentPreference(request: request)) + } func getMemberCan() -> AnyPublisher { return api.requestPublisher(.getMemberInfo) diff --git a/docs/20260326_회원정보응답확장및콘텐츠보기설정연동.md b/docs/20260326_회원정보응답확장및콘텐츠보기설정연동.md new file mode 100644 index 0000000..52ec28b --- /dev/null +++ b/docs/20260326_회원정보응답확장및콘텐츠보기설정연동.md @@ -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 환경 한계로 미실행(체크리스트 유지) diff --git a/docs/20260327_마이페이지본인인증아이템국가조건적용.md b/docs/20260327_마이페이지본인인증아이템국가조건적용.md new file mode 100644 index 0000000..a5af5d8 --- /dev/null +++ b/docs/20260327_마이페이지본인인증아이템국가조건적용.md @@ -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 노출 확인 필요) diff --git a/docs/20260327_캐릭터리스트콘텐츠설정이동안내표시개선.md b/docs/20260327_캐릭터리스트콘텐츠설정이동안내표시개선.md new file mode 100644 index 0000000..1ae3731 --- /dev/null +++ b/docs/20260327_캐릭터리스트콘텐츠설정이동안내표시개선.md @@ -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 → 캐릭터 탭 → 콘텐츠 설정 진입 직후 안내 토스트 노출 확인 필요) diff --git a/docs/20260327_캐릭터상세진입인증국가분기적용.md b/docs/20260327_캐릭터상세진입인증국가분기적용.md new file mode 100644 index 0000000..b85e161 --- /dev/null +++ b/docs/20260327_캐릭터상세진입인증국가분기적용.md @@ -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 분기 확인 필요) diff --git a/docs/20260327_콘텐츠설정PATCH변경필드옵셔널전송.md b/docs/20260327_콘텐츠설정PATCH변경필드옵셔널전송.md new file mode 100644 index 0000000..52649b0 --- /dev/null +++ b/docs/20260327_콘텐츠설정PATCH변경필드옵셔널전송.md @@ -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에 포함되도록 반영 완료