From abe939e768e07997bda87e2586666d67115262ad Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Fri, 13 Mar 2026 13:56:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(notification):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=88=98=EC=8B=A0=20=EC=84=A4=EC=A0=95=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=99=80=20=EC=9D=B4=EB=8F=99=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive/Resources/Localizable.xcstrings | 35 +-- SodaLive/Sources/App/AppStep.swift | 2 + SodaLive/Sources/ContentView.swift | 3 + .../List/PushNotificationListView.swift | 2 +- .../NotificationSettingsView.swift | 216 ++++++++++++++++ .../NotificationSettingsViewModel.swift | 240 ++++++++++++++++++ docs/20260313_알림수신설정페이지구현.md | 32 +++ 7 files changed, 513 insertions(+), 17 deletions(-) create mode 100644 docs/20260313_알림수신설정페이지구현.md diff --git a/SodaLive/Resources/Localizable.xcstrings b/SodaLive/Resources/Localizable.xcstrings index cf866ef..af92172 100644 --- a/SodaLive/Resources/Localizable.xcstrings +++ b/SodaLive/Resources/Localizable.xcstrings @@ -4142,22 +4142,6 @@ } } }, - "목" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thu" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "木" - } - } - } - }, "모집완료" : { "localizations" : { "en" : { @@ -4190,6 +4174,22 @@ } } }, + "목" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thu" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "木" + } + } + } + }, "무료" : { "localizations" : { "en" : { @@ -5213,6 +5213,9 @@ } } } + }, + "서비스 알림" : { + }, "설정" : { "localizations" : { diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index ce9ed6f..a1a19fd 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -41,6 +41,8 @@ enum AppStep { case privacy case notificationSettings + + case notificationReceiveSettings case contentViewSettings diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index d7d6f38..6c9d71f 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -147,6 +147,9 @@ struct AppStepLayerView: View { case .notificationSettings: NotificationSettingsView() + case .notificationReceiveSettings: + NotificationReceiveSettingsView() + case .contentViewSettings: ContentSettingsView() diff --git a/SodaLive/Sources/Notification/List/PushNotificationListView.swift b/SodaLive/Sources/Notification/List/PushNotificationListView.swift index b31621f..227bac7 100644 --- a/SodaLive/Sources/Notification/List/PushNotificationListView.swift +++ b/SodaLive/Sources/Notification/List/PushNotificationListView.swift @@ -87,7 +87,7 @@ struct PushNotificationListView: View { Image("ic_bell_settings") .contentShape(Rectangle()) .onTapGesture { - AppState.shared.setAppStep(step: .notificationSettings) + AppState.shared.setAppStep(step: .notificationReceiveSettings) } } .padding(.horizontal, 13.3) diff --git a/SodaLive/Sources/Settings/Notification/NotificationSettingsView.swift b/SodaLive/Sources/Settings/Notification/NotificationSettingsView.swift index d6ea5ba..99d2fcc 100644 --- a/SodaLive/Sources/Settings/Notification/NotificationSettingsView.swift +++ b/SodaLive/Sources/Settings/Notification/NotificationSettingsView.swift @@ -113,3 +113,219 @@ struct NotificationSettingsView_Previews: PreviewProvider { NotificationSettingsView() } } + +struct NotificationReceiveSettingsView: View { + + @StateObject var viewModel = NotificationReceiveSettingsViewModel() + + @State private var isInitialized = false + @State private var isShowFollowNotifyDialog = false + @State private var creatorId = 0 + @State private var selectedItemIndex = -1 + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 0) { + DetailNavigationBar(title: "알림 수신 설정") + + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + Text("서비스 알림") + .appFont(size: 16.7, weight: .bold) + .foregroundColor(Color(hex: "eeeeee")) + .padding(.top, 26.7) + .padding(.horizontal, 13.3) + + VStack(spacing: 0) { + NotificationReceiveServiceRowView( + title: "라이브 알림", + isOn: viewModel.followingChannelLive, + onTapToggle: { + viewModel.followingChannelLive.toggle() + } + ) + + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.3)) + + NotificationReceiveServiceRowView( + title: "콘텐츠 업로드 알림", + isOn: viewModel.followingChannelUploadContent, + onTapToggle: { + viewModel.followingChannelUploadContent.toggle() + } + ) + + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.3)) + + NotificationReceiveServiceRowView( + title: "메시지 알림", + isOn: viewModel.message, + onTapToggle: { + viewModel.message.toggle() + } + ) + } + .padding(.vertical, 6.7) + .padding(.horizontal, 13.3) + .background(Color(hex: "222222")) + .cornerRadius(10) + .padding(.top, 13.3) + .padding(.horizontal, 13.3) + + Text("팔로잉 채널") + .appFont(size: 16.7, weight: .bold) + .foregroundColor(Color(hex: "eeeeee")) + .padding(.top, 26.7) + .padding(.horizontal, 13.3) + + HStack(spacing: 0) { + Text("총") + .appFont(size: 13.3, weight: .medium) + .foregroundColor(Color.grayee) + + Text(" \(viewModel.totalCount) ") + .appFont(size: 13.3, weight: .medium) + .foregroundColor(Color.mainRed3) + + Text("명") + .appFont(size: 13.3, weight: .medium) + .foregroundColor(Color.grayee) + + Spacer() + } + .padding(.top, 13.3) + .padding(.horizontal, 13.3) + + if viewModel.totalCount > 0 { + VStack(spacing: 13.3) { + ForEach(0.. Void + + var body: some View { + HStack(spacing: 0) { + Text(title) + .appFont(size: 15, weight: .bold) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Image(isOn ? "btn_toggle_on_big" : "btn_toggle_off_big") + .resizable() + .frame(width: 44, height: 27) + .onTapGesture { + onTapToggle() + } + } + .frame(height: 50) + } +} diff --git a/SodaLive/Sources/Settings/Notification/NotificationSettingsViewModel.swift b/SodaLive/Sources/Settings/Notification/NotificationSettingsViewModel.swift index 5bb9b18..389f834 100644 --- a/SodaLive/Sources/Settings/Notification/NotificationSettingsViewModel.swift +++ b/SodaLive/Sources/Settings/Notification/NotificationSettingsViewModel.swift @@ -91,3 +91,243 @@ final class NotificationSettingsViewModel: ObservableObject { } } } + +final class NotificationReceiveSettingsViewModel: ObservableObject { + + private let userRepository = UserRepository() + private let followRepository = FollowCreatorRepository() + private var subscription = Set() + + @Published var followingChannelLive = false { + didSet { + submit(live: followingChannelLive) + } + } + + @Published var followingChannelUploadContent = false { + didSet { + submit(uploadContent: followingChannelUploadContent) + } + } + + @Published var message = false { + didSet { + submit(message: message) + } + } + + @Published var isLoading = false + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var creatorList = [GetCreatorFollowingAllListItem]() + @Published var totalCount = 0 + + private var page = 1 + private var isLast = false + private let pageSize = 20 + private var isMemberInfoLoading = false + private var isFollowLoading = false + + func initialize() { + getMemberInfo() + getFollowedCreatorAllList(reset: true) + } + + func getFollowedCreatorAllList(reset: Bool = false) { + if reset { + page = 1 + isLast = false + totalCount = 0 + creatorList.removeAll() + } + + if isLast || isFollowLoading { + return + } + + isFollowLoading = true + updateLoading() + + followRepository.getFollowedCreatorAllList(page: page, size: pageSize) + .sink { [weak self] result in + guard let self = self else { return } + + switch result { + case .finished: + DEBUG_LOG("finish") + + case .failure(let error): + self.isFollowLoading = false + self.updateLoading() + ERROR_LOG(error.localizedDescription) + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + + self.isFollowLoading = false + self.updateLoading() + + do { + let decoded = try JSONDecoder().decode( + ApiResponse.self, + from: response.data + ) + + if let data = decoded.data, decoded.success { + if self.page == 1 { + self.creatorList.removeAll() + } + + self.totalCount = data.totalCount + + if !data.items.isEmpty { + self.page += 1 + self.creatorList.append(contentsOf: data.items) + } else { + self.isLast = true + } + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = I18n.Common.commonError + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func creatorFollow(creatorId: Int, index: Int, follow: Bool = true, notify: Bool = true) { + if index < 0 || index >= creatorList.count { + return + } + + isFollowLoading = true + updateLoading() + + userRepository.creatorFollow(creatorId: creatorId, follow: follow, notify: notify) + .sink { [weak self] result in + guard let self = self else { return } + + switch result { + case .finished: + DEBUG_LOG("finish") + + case .failure(let error): + self.isFollowLoading = false + self.updateLoading() + ERROR_LOG(error.localizedDescription) + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + + self.isFollowLoading = false + self.updateLoading() + + do { + let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) + + if decoded.success { + var creator = self.creatorList[index] + creator.isFollow = follow + creator.isNotify = notify + self.creatorList.remove(at: index) + self.creatorList.insert(creator, at: index) + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = I18n.Common.commonError + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + private func getMemberInfo() { + isMemberInfoLoading = true + updateLoading() + + userRepository.getMemberInfo() + .sink { [weak self] result in + guard let self = self else { return } + + switch result { + case .finished: + DEBUG_LOG("finish") + + case .failure(let error): + self.isMemberInfoLoading = false + self.updateLoading() + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + + self.isMemberInfoLoading = false + self.updateLoading() + + do { + let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) + + if let data = decoded.data, decoded.success { + self.followingChannelLive = data.followingChannelLiveNotice ?? false + self.followingChannelUploadContent = data.followingChannelUploadContentNotice ?? false + self.message = data.messageNotice ?? false + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = I18n.Common.commonError + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + private func submit(live: Bool? = nil, uploadContent: Bool? = nil, message: Bool? = nil) { + if isMemberInfoLoading || (live == nil && uploadContent == nil && message == nil) { + return + } + + userRepository + .updateNotificationSettings(live: live, uploadContent: uploadContent, message: message) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { _ in + } + .store(in: &subscription) + } + + private func updateLoading() { + isLoading = isMemberInfoLoading || isFollowLoading + } +} diff --git a/docs/20260313_알림수신설정페이지구현.md b/docs/20260313_알림수신설정페이지구현.md new file mode 100644 index 0000000..0679fb2 --- /dev/null +++ b/docs/20260313_알림수신설정페이지구현.md @@ -0,0 +1,32 @@ +# 20260313 알림 수신 설정 페이지 구현 + +- [x] 기존 알림 리스트 우측 상단 아이콘 탭 이동 경로를 신규 페이지로 연결 +- [x] 신규 알림 수신 설정 화면(View) 추가 +- [x] 서비스 알림 섹션을 기존 `NotificationSettingsView`와 동일 UI/액션으로 구현 +- [x] 팔로잉 채널 섹션을 기존 `FollowCreatorView`와 동일 UI로 구현 +- [x] 서비스 알림 + 팔로잉 채널을 하나의 전체 스크롤로 구성 +- [x] 팔로잉 채널 무한 스크롤 페이징 적용(요청 단위 20개) +- [x] 관련 라우팅(`AppStep`, `ContentView`) 반영 +- [x] 정적 진단/빌드/테스트 검증 수행 및 결과 기록 + +## 검증 기록 + +### 1차 +- 무엇/왜/어떻게: 구현 후 `lsp_diagnostics`, 빌드, 테스트를 실행해 신규 페이지와 라우팅/페이징 변경의 안정성을 확인했다. +- 실행 명령: + - `lsp_diagnostics`: + - `SodaLive/Sources/Settings/Notification/NotificationSettingsViewModel.swift` + - `SodaLive/Sources/Settings/Notification/NotificationSettingsView.swift` + - `SodaLive/Sources/App/AppStep.swift` + - `SodaLive/Sources/ContentView.swift` + - `SodaLive/Sources/Notification/List/PushNotificationListView.swift` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` +- 결과: + - `lsp_diagnostics`: SourceKit이 프로젝트 전역 심볼(`BaseView`, `AppState`, `UserRepository` 등)을 해석하지 못해 수정 범위와 무관한 대량 unresolved 오류가 출력됨(현재 로컬 LSP 구성 한계). + - `SodaLive` Debug 빌드: 성공(`** BUILD SUCCEEDED **`). + - `SodaLive-dev` Debug 빌드: 성공(`** BUILD SUCCEEDED **`). + - `SodaLive` 테스트: 실패 - `Scheme SodaLive is not currently configured for the test action.` + - `SodaLive-dev` 테스트: 실패 - `Scheme SodaLive-dev is not currently configured for the test action.`