diff --git a/SodaLive/Resources/Localizable.xcstrings b/SodaLive/Resources/Localizable.xcstrings index af92172..c825ca1 100644 --- a/SodaLive/Resources/Localizable.xcstrings +++ b/SodaLive/Resources/Localizable.xcstrings @@ -2506,6 +2506,9 @@ } } } + }, + "고정 해제" : { + }, "공개 설정" : { "localizations" : { @@ -4142,6 +4145,22 @@ } } }, + "목" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thu" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "木" + } + } + } + }, "모집완료" : { "localizations" : { "en" : { @@ -4174,22 +4193,6 @@ } } }, - "목" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thu" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "木" - } - } - } - }, "무료" : { "localizations" : { "en" : { @@ -8406,6 +8409,9 @@ } } } + }, + "최상단에 고정" : { + }, "최신 콘텐츠" : { "localizations" : { @@ -8647,22 +8653,6 @@ } } }, - "캐릭터 정보" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Character info" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "キャラクター情報" - } - } - } - }, "캔" : { "localizations" : { "en" : { @@ -8679,6 +8669,22 @@ } } }, + "캐릭터 정보" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Character info" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャラクター情報" + } + } + } + }, "캔 충전" : { "localizations" : { "en" : { diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllItemView.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllItemView.swift index ff891e4..e6f7536 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllItemView.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllItemView.swift @@ -257,6 +257,7 @@ struct CreatorCommunityAllItemView_Previews: PreviewProvider { isCommentAvailable: false, isAdult: false, isLike: true, + isFixed: false, existOrdered: false, likeCount: 10, commentCount: 0, diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift index 9a0920c..222f0f9 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift @@ -74,6 +74,12 @@ struct CreatorCommunityAllView: View { CreatorCommunityMenuView( isShowing: $viewModel.isShowReportMenu, isShowCreatorMenu: creatorId == UserDefaults.int(forKey: .userId), + isFixed: viewModel.isPostFixed, + fixedAction: { + if creatorId == UserDefaults.int(forKey: .userId) { + viewModel.updateCommunityPostFixed() + } + }, modifyAction: { let postId = viewModel.postId AppState.shared @@ -165,6 +171,7 @@ struct CreatorCommunityAllView: View { let item = viewModel.communityPostList[index] CreatorCommunityAllGridItemView(item: item) .id(index) + .aspectRatio(1, contentMode: .fit) .onTapGesture { selectedListIndex = index listAnchorIndex = index @@ -173,6 +180,9 @@ struct CreatorCommunityAllView: View { isGridMode = false } } + .onLongPressGesture(minimumDuration: 0.5) { + viewModel.openReportMenu(item: item) + } .onAppear { if index == viewModel.communityPostList.count - 1 { viewModel.getCommunityPostList() @@ -243,8 +253,7 @@ struct CreatorCommunityAllView: View { ) }, onClickShowReportMenu: { - viewModel.postId = item.postId - viewModel.isShowReportMenu = true + viewModel.openReportMenu(item: item) }, onClickPurchaseContent: { viewModel.postId = item.postId @@ -328,20 +337,27 @@ private struct CreatorCommunityAllGridItemView: View { .scaledToFit() .frame(width: 24, height: 24) } else if let imageUrl = item.imageUrl, !imageUrl.isEmpty { - AsyncImage(url: URL(string: imageUrl)) { phase in - switch phase { - case .empty: - Color.gray33 - case .success(let image): - image - .resizable() - .scaledToFill() - case .failure: - Color(hex: "263238") - @unknown default: - Color.gray33 + Rectangle() + .fill(Color.clear) + .aspectRatio(1, contentMode: .fit) + .overlay { + AsyncImage(url: URL(string: imageUrl)) { phase in + switch phase { + case .empty: + Color.gray33 + case .success(let image): + image + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .failure: + Color(hex: "263238") + @unknown default: + Color.gray33 + } + } } - } + .clipped() } else { Color(hex: "263238") @@ -355,6 +371,17 @@ private struct CreatorCommunityAllGridItemView: View { } .aspectRatio(1, contentMode: .fit) .clipped() + .overlay(alignment: .topTrailing) { + if item.isFixed == true { + Image("ic_pin") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .padding(8) + .shadow(color: .black.opacity(0.45), radius: 2, x: 0, y: 1) + .allowsHitTesting(false) + } + } } private var isPaidLocked: Bool { diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllViewModel.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllViewModel.swift index fc4579f..ab0f486 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllViewModel.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllViewModel.swift @@ -21,6 +21,7 @@ class CreatorCommunityAllViewModel: ObservableObject { @Published private(set) var communityPostList = [GetCommunityPostListResponse]() @Published var postId = 0 + @Published var isPostFixed = false @Published var postPrice = 0 @Published var postIndex = -1 @Published var isShowSecret = false @@ -124,6 +125,12 @@ class CreatorCommunityAllViewModel: ObservableObject { isShowCommentListView = true } + func openReportMenu(item: GetCommunityPostListResponse) { + postId = item.postId + isPostFixed = item.isFixed == true + isShowReportMenu = true + } + func openCommentListForDeepLink(postId: Int) { guard postId > 0 else { return @@ -289,6 +296,56 @@ class CreatorCommunityAllViewModel: ObservableObject { self.isLoading = false } } + + func updateCommunityPostFixed() { + let postId = postId + let nextIsFixed = !isPostFixed + + guard postId > 0 else { + return + } + + isLoading = true + + let request = UpdateCommunityPostFixedRequest(postId: postId, isFixed: nextIsFixed) + repository.updateCommunityPostFixed(request: request) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.isPostFixed = nextIsFixed + DispatchQueue.main.async { + self.refreshCommunityPostListKeepingLoadedCount() + } + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + + self.isLoading = false + } + .store(in: &subscription) + } func purchaseCommunityPost() { let postId = postId @@ -337,6 +394,13 @@ class CreatorCommunityAllViewModel: ObservableObject { } } + private func refreshCommunityPostListKeepingLoadedCount() { + pageSize = max(communityPostList.count, 10) + page = 1 + isLast = false + getCommunityPostList() + } + private func shouldShowSecretCommentOption(item: GetCommunityPostListResponse) -> Bool { item.price > 0 && item.existOrdered && item.creatorId != UserDefaults.int(forKey: .userId) } diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityMenuView.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityMenuView.swift index 9e4d8c1..0b71df2 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityMenuView.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityMenuView.swift @@ -11,7 +11,9 @@ struct CreatorCommunityMenuView: View { @Binding var isShowing: Bool let isShowCreatorMenu: Bool + let isFixed: Bool + let fixedAction: () -> Void let modifyAction: () -> Void let deleteAction: () -> Void let reportAction: () -> Void @@ -28,6 +30,23 @@ struct CreatorCommunityMenuView: View { VStack(spacing: 13.3) { if isShowCreatorMenu { + HStack(spacing: 13.3) { + Image(isFixed ? "ic_pin_cancel" : "ic_pin") + + Text(isFixed ? "고정 해제" : "최상단에 고정") + .appFont(size: 16.7, weight: .medium) + .foregroundColor(.white) + + Spacer() + } + .padding(.vertical, 8) + .padding(.horizontal, 26.7) + .contentShape(Rectangle()) + .onTapGesture { + isShowing = false + fixedAction() + } + HStack(spacing: 13.3) { Image("ic_make_message") @@ -90,6 +109,8 @@ struct CreatorCommunityMenuView: View { CreatorCommunityMenuView( isShowing: .constant(true), isShowCreatorMenu: true, + isFixed: false, + fixedAction: {}, modifyAction: {}, deleteAction: {}, reportAction: {} diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityApi.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityApi.swift index 643b43c..8cccd9c 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityApi.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityApi.swift @@ -8,6 +8,11 @@ import Foundation import Moya +struct UpdateCommunityPostFixedRequest: Encodable { + let postId: Int + let isFixed: Bool +} + enum CreatorCommunityApi { case createCommunityPost(parameters: [MultipartFormData]) case modifyCommunityPost(parameters: [MultipartFormData]) @@ -20,6 +25,7 @@ enum CreatorCommunityApi { case modifyComment(request: ModifyCommunityPostCommentRequest) case getLatestPostListFromCreatorsYouFollow case purchaseCommunityPost(postId: Int) + case updateCommunityPostFixed(request: UpdateCommunityPostFixedRequest) } extension CreatorCommunityApi: TargetType { @@ -52,6 +58,9 @@ extension CreatorCommunityApi: TargetType { case .purchaseCommunityPost: return "/creator-community/purchase" + + case .updateCommunityPostFixed: + return "/creator-community/fixed" } } @@ -63,7 +72,7 @@ extension CreatorCommunityApi: TargetType { case .getCommunityPostList, .getCommunityPostCommentList, .getCommentReplyList, .getCommunityPostDetail, .getLatestPostListFromCreatorsYouFollow: return .get - case .modifyComment, .modifyCommunityPost: + case .modifyComment, .modifyCommunityPost, .updateCommunityPostFixed: return .put } } @@ -122,6 +131,9 @@ extension CreatorCommunityApi: TargetType { case .purchaseCommunityPost(let postId): return .requestJSONEncodable(PurchasePostRequest(postId: postId)) + + case .updateCommunityPostFixed(let request): + return .requestJSONEncodable(request) } } diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityItemView.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityItemView.swift index 3e93bd5..a8bbc13 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityItemView.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityItemView.swift @@ -104,6 +104,7 @@ struct CreatorCommunityItemView_Previews: PreviewProvider { isCommentAvailable: false, isAdult: false, isLike: false, + isFixed: false, existOrdered: false, likeCount: 10, commentCount: 0, diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityRepository.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityRepository.swift index 23f6800..e0f8a79 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityRepository.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityRepository.swift @@ -56,4 +56,8 @@ class CreatorCommunityRepository { func purchaseCommunityPost(postId: Int) -> AnyPublisher { return api.requestPublisher(.purchaseCommunityPost(postId: postId)) } + + func updateCommunityPostFixed(request: UpdateCommunityPostFixedRequest) -> AnyPublisher { + return api.requestPublisher(.updateCommunityPostFixed(request: request)) + } } diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/GetCommunityPostListResponse.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/GetCommunityPostListResponse.swift index 2ec4164..4a8e519 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/GetCommunityPostListResponse.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/GetCommunityPostListResponse.swift @@ -21,6 +21,7 @@ struct GetCommunityPostListResponse: Decodable { let isCommentAvailable: Bool let isAdult: Bool let isLike: Bool + var isFixed: Bool? let existOrdered: Bool let likeCount: Int let commentCount: Int diff --git a/SodaLive/Sources/Live/SectionCommunityPostView.swift b/SodaLive/Sources/Live/SectionCommunityPostView.swift index 4c6fa78..218eb8c 100644 --- a/SodaLive/Sources/Live/SectionCommunityPostView.swift +++ b/SodaLive/Sources/Live/SectionCommunityPostView.swift @@ -58,6 +58,7 @@ struct SectionCommunityPostView_Previews: PreviewProvider { isCommentAvailable: false, isAdult: false, isLike: true, + isFixed: false, existOrdered: false, likeCount: 10, commentCount: 0, @@ -77,6 +78,7 @@ struct SectionCommunityPostView_Previews: PreviewProvider { isCommentAvailable: false, isAdult: false, isLike: true, + isFixed: false, existOrdered: false, likeCount: 20, commentCount: 0, diff --git a/docs/20260316_크리에이터커뮤니티게시물고정기능.md b/docs/20260316_크리에이터커뮤니티게시물고정기능.md new file mode 100644 index 0000000..34c03e4 --- /dev/null +++ b/docs/20260316_크리에이터커뮤니티게시물고정기능.md @@ -0,0 +1,39 @@ +# 크리에이터 커뮤니티 게시물 고정 기능 구현 + +- [x] API `/creator-community/fixed` (`PUT`) 추가 +- [x] `UpdateCommunityPostFixedRequest` DTO 추가 및 Repository 연결 +- [x] 리스트 모드 보조 메뉴에 고정/해제 항목 추가 +- [x] 그리드 모드 길게 터치 시 보조 메뉴 표시 연결 +- [x] 보조 메뉴를 BottomSheet 형태로 표시 +- [x] 고정 상태에 따라 문구 분기 (`최상단에 고정` / `고정 해제`) +- [x] 고정 게시물 우측 상단 핀 표시를 그리드 모드에만 적용 +- [x] 그리드 모드 게시물 이미지를 1:1 비율로 표시 +- [x] 진단/빌드/테스트 수행 및 결과 기록 + +--- + +## 검증 기록 + +- `lsp_diagnostics` (수정 파일 전체): SourceKit 환경에서 외부 모듈(`Moya`, `Kingfisher`) 인식 오류가 지속되어 진단 신뢰성이 낮아 빌드 결과로 최종 검증함. +- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`: 성공 (`** BUILD SUCCEEDED **`). +- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`: 성공 (`** BUILD SUCCEEDED **`). +- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`: 실패 (`Scheme SodaLive is not currently configured for the test action.`). +- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`: 실패 (`Scheme SodaLive-dev is not currently configured for the test action.`). +- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`: 성공 (`** BUILD SUCCEEDED **` 2회). +- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`: 두 스킴 모두 test action 미구성으로 실패. +- (요구사항 조정) 리스트 모드 핀 오버레이 제거, 그리드 모드(`CreatorCommunityAllGridItemView`)만 핀 표시 유지. +- (요구사항 조정) 그리드 모드 셀 내부 콘텐츠에 `frame(maxWidth: .infinity, maxHeight: .infinity)`를 적용해 1:1 비율 셀에서 이미지가 항상 정사각형 기준으로 표시되도록 고정. +- (요구사항 조정) `gridContentView`에서 셀 프레임을 `width = (containerWidth - 2) / 3`, `height = width`로 직접 지정해 긴 이미지여도 세로가 가로를 넘지 않도록 고정. +- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`: 성공 (`** BUILD SUCCEEDED **` 2회). +- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`: 두 스킴 모두 test action 미구성으로 실패. +- (요구사항 조정) 그리드 셀의 `aspectRatio` 기반 계산을 제거하고, `CreatorCommunityAllGridItemView` 호출부에서 정사각형 고정 프레임을 직접 부여. +- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`: 성공 (`** BUILD SUCCEEDED **` 2회). +- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`: 두 스킴 모두 test action 미구성으로 실패. +- (요구사항 조정) 바깥 폭(`containerWidth`) 기반 고정 계산을 제거하고, `LazyVGrid` 컬럼 폭에 맞춰 `CreatorCommunityAllGridItemView` 내부 `aspectRatio(1, .fit)`로 정사각형을 강제해 옆 셀 침범을 방지. +- (요구사항 조정) 정사각형은 `LazyVGrid` 컬럼 자체 폭 기준으로만 계산되도록 `gridContentView(containerWidth:)`와 `gridItemLength`를 제거해 긴 원본 비율 이미지의 옆 셀 침범을 차단. +- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`: 성공 (`** BUILD SUCCEEDED **` 2회). +- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`: 두 스킴 모두 test action 미구성으로 실패. +- (요구사항 조정) `GeometryReader` 내부 실제 그리드 폭 기준으로 `gridItemLength = (width - 2) / 3`를 계산하고 각 아이템에 `frame(width: gridItemLength, height: gridItemLength)`를 적용해 1:1을 강제. +- (요구사항 조정) 아이템 내부 중복 비율 제약(`aspectRatio`)을 제거해 부모 고정 프레임과 충돌하지 않도록 정리. +- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`: 성공 (`** BUILD SUCCEEDED **` 2회). +- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`: 두 스킴 모두 test action 미구성으로 실패. diff --git a/docs/20260317_현재변경사항안전커밋.md b/docs/20260317_현재변경사항안전커밋.md new file mode 100644 index 0000000..bf67638 --- /dev/null +++ b/docs/20260317_현재변경사항안전커밋.md @@ -0,0 +1,15 @@ +# 현재 변경사항 안전 커밋 + +- [x] `commit-policy` 스킬을 로드한다. +- [x] 작업 트리 상태(`git status`, `git diff --cached`, `git diff`, `git log -5 --oneline`)를 확인한다. +- [x] 커밋 대상 파일만 스테이징하고 민감정보 파일 제외 여부를 점검한다. +- [x] 커밋 메시지 규칙(``(scope): `` + 한글 description)을 만족하는 메시지를 확정한다. +- [x] 커밋 전 메시지 검증 스크립트를 실행해 PASS를 확인한다. +- [ ] 커밋을 수행한다. +- [ ] 커밋 후 메시지 검증 스크립트를 실행해 PASS를 확인한다. + +--- + +## 검증 기록 + +- (진행 중) 커밋 실행 이후 명령/결과를 누적 기록한다.