From be6e4a4ab05ec8053bf4c15f8cdd4d4315d46d8e Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 23:52:06 +0900 Subject: [PATCH 01/39] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=A7=80=20=ED=83=80=EC=9D=B4=EB=B0=8D=20?= =?UTF-8?q?=E2=80=94=20nodeEndTime=EC=9D=84=20=EB=8B=A4=EC=9D=8C=20?= =?UTF-8?q?=EB=85=B8=EB=93=9C=20=EC=8B=9C=EC=9E=91=20=EC=8B=9C=EA=B0=81=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD(?= =?UTF-8?q?=EB=A7=88=EC=A7=80=EB=A7=89=20=EB=8C=80=EC=82=AC=20=EB=81=8A?= =?UTF-8?q?=EA=B9=80=20=EB=B0=A9=EC=A7=80)=20#8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Scenario/BattleScenario+.swift | 17 +++++++++++++++-- .../ChatRoom/Reducer/ChatRoomFeature.swift | 5 ++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift index ab2522c5..6836735c 100644 --- a/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift +++ b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift @@ -1,5 +1,5 @@ // -// BattleScenario+Timeline.swift +// BattleScenario+.swift // Entity // // 시나리오 타임라인 계산. @@ -14,8 +14,21 @@ public extension BattleScenario { return TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 } - /// 노드 종료 시간(초) = 노드 시작 + audioDuration. + /// 노드 종료 시간(초). + /// 다음 노드(autoNext 또는 인터랙티브 분기)의 시작 시각이 실제 오디오 경계이므로 우선 사용한다. + /// (audioDuration 합산은 실제 오디오와 어긋나, 선택지가 마지막 대사 도중에 떠 음성이 끊기는 문제가 있음) + /// 다음 노드가 없으면(클로징) 노드 시작 + audioDuration. func nodeEndTime(for node: ScenarioNode) -> TimeInterval { + let nextNodeIds: [Int] = { + if let auto = node.autoNextNodeId { return [auto] } + return node.interactiveOptions.map(\.nextNodeId) + }() + let nextStarts = nextNodeIds.compactMap { id in + nodes.first(where: { $0.nodeId == id })?.scripts.map(\.startTimeMs).min() + } + if let nextStart = nextStarts.min() { + return TimeInterval(nextStart) / 1000 + } let start = TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 return start + TimeInterval(node.audioDuration) } diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index ac3132cf..f1f3a85b 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -524,12 +524,15 @@ extension ChatRoomFeature { else { return nil } let nodeEndTime = state.nodeEndTime(for: currentNode) - guard time >= nodeEndTime - 0.25 else { return nil } + guard time >= nodeEndTime else { return nil } if !currentNode.interactiveOptions.isEmpty { guard !state.isWaitingForNodeSelection else { return nil } state.isWaitingForNodeSelection = true state.isPlaying = false + // 선택지 노출 시점에 이 노드의 대사가 전부 드러나도록 currentTime 을 노드 끝으로 고정 + // (음성이 끝나기 전 일찍 멈춰 글이 튀어 보이던 문제 방지). + state.currentTime = max(state.currentTime, nodeEndTime) return .run { [player = audioPlayer] _ in await player.pause() } From 8f256ed91b59abacba4238bca778e1a7d625567d Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 23:52:07 +0900 Subject: [PATCH 02/39] =?UTF-8?q?fix:=20=EB=8C=93=EA=B8=80=20=E2=80=94=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=ED=9B=84=20=EC=8B=A4=EC=A0=9C=20=EC=A7=84?= =?UTF-8?q?=EC=98=81=20=ED=83=AD=20=EC=9E=90=EB=8F=99=EC=A0=84=ED=99=98=20?= =?UTF-8?q?+=20=ED=95=84=ED=84=B0=20=ED=83=AD=20=EC=97=B0=EC=86=8D=20?= =?UTF-8?q?=EB=B2=A0=EC=9D=B4=EC=8A=A4=EB=9D=BC=EC=9D=B8/=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EC=A0=95=EB=A6=AC=20#8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Chat/Sources/Comment/Reducer/CommentFeature.swift | 11 ++++++++++- .../Chat/Sources/Comment/View/CommentView.swift | 10 ++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index b7009e3b..6b55b8d1 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -569,7 +569,16 @@ extension CommentFeature { case let .createCommentResponse(result): state.isSubmitting = false switch result { - case .success: + case let .success(perspective): + // 서버는 댓글을 "유저가 투표한 진영"에 저장한다(요청 optionId 무시). 응답의 실제 진영 + // (perspective.option)으로 필터를 전환해 방금 쓴 댓글이 그 진영 탭에서 보이도록 한다. + let votedOptionId = perspective.option.optionId + state.myOptionId = votedOptionId + if votedOptionId == state.voteSummary.optionA.optionId { + state.selectedFilter = .optionA + } else if votedOptionId == state.voteSummary.optionB.optionId { + state.selectedFilter = .optionB + } return .send(.async(.fetchPerspectives(reset: true))) case let .failure(error): Log.error("[CommentFeature] createComment failed: \(error.localizedDescription)") diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index 622034cd..f3d7e04b 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -261,6 +261,12 @@ private extension CommentView { } } .frame(maxWidth: .infinity) + // 전체 탭을 가로지르는 연속 베이스 라인 (셀별로 끊겨 보이던 문제 해결). + .overlay(alignment: .bottom) { + Rectangle() + .fill(.neutral200) + .frame(height: 1) + } } @ViewBuilder @@ -295,8 +301,8 @@ private extension CommentView { .contentShape(Rectangle()) .overlay(alignment: .bottom) { Rectangle() - .fill(isSelected ? Color.primary500 : Color.neutral200) - .frame(height: isSelected ? 2.5 : 1) + .fill(isSelected ? Color.primary500 : Color.clear) + .frame(height: 2.5) } } .buttonStyle(.plain) From 8fafb78f661b92cf946c5d11a16610335f11dadf Mon Sep 17 00:00:00 2001 From: Roy Date: Sat, 6 Jun 2026 23:52:07 +0900 Subject: [PATCH 03/39] =?UTF-8?q?feat:=20=ED=99=88=20'=EB=8D=94=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0'=20=ED=83=AD=20=EC=8B=9C=20=ED=83=90=EC=83=89=20?= =?UTF-8?q?=ED=83=AD=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20#8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Sources/Main/Reducer/HomeFeature.swift | 8 +++++--- .../MainTab/Sources/Reducer/MainTabCoordinator.swift | 6 ++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift index 00cbe01e..ba0b0101 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -7,10 +7,10 @@ import ComposableArchitecture import DomainInterface -import UseCase import Entity import Foundation import LogMacro +import UseCase @Reducer public struct HomeFeature { @@ -72,6 +72,8 @@ public struct HomeFeature { public enum DelegateAction: Equatable { case presentPreVote(battleId: Int) + /// "더보기" → 탐색 탭으로 이동. + case moveToExplore } nonisolated enum CancelID: Hashable { @@ -114,7 +116,7 @@ extension HomeFeature { return .send(.async(.fetchHome)) case .seeMoreTapped: - return .none + return .send(.delegate(.moveToExplore)) case let .voteTapped(question): return .send(.delegate(.presentPreVote(battleId: question.battleId))) @@ -182,7 +184,7 @@ extension HomeFeature { action: DelegateAction ) -> Effect { switch action { - case .presentPreVote: + case .presentPreVote, .moveToExplore: .none } } diff --git a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift index a1f326eb..1c2c4022 100644 --- a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift +++ b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift @@ -114,6 +114,12 @@ public struct MainTabCoordinator { state.selectedTab = state.previousTab return .none + // 홈 "더보기" → 탐색 탭으로 이동 + case .home(.router(.routeAction(_, action: .home(.delegate(.moveToExplore))))): + if state.selectedTab != Tab.explore.rawValue { state.previousTab = state.selectedTab } + state.selectedTab = Tab.explore.rawValue + return .none + case .home, .explore, .quickBattle, .myPage: return .none } From 0032d5788edd9872f626eff53b642b18bcfc7437 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 01:03:46 +0900 Subject: [PATCH 04/39] =?UTF-8?q?fix:=20QA=20=EB=A0=88=EC=9D=B8=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EB=B2=88=ED=98=B8=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=A6=9D=EA=B0=80=20=E2=80=94=20TestFlight=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Project+Templete/Extension+String.swift | 2 +- Projects/Presentation/Profile/Project.swift | 19 +++++ .../Presentation/Profile/Sources/Base.swift | 22 +++++ .../Components/PointHistorySkeletonView.swift | 80 +++++++++++++++++++ .../Profile/Tests/Sources/rofileTests.swift | 27 +++++++ .../Profile/Contents.json | 6 ++ .../Profile/history.imageset/Contents.json | 12 +++ .../Profile/history.imageset/history.svg | 5 ++ fastlane/Fastfile | 42 ++++++++++ 9 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 Projects/Presentation/Profile/Project.swift create mode 100644 Projects/Presentation/Profile/Sources/Base.swift create mode 100644 Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift create mode 100644 Projects/Presentation/Profile/Tests/Sources/rofileTests.swift create mode 100644 Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/Contents.json create mode 100644 Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/history.imageset/Contents.json create mode 100644 Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/history.imageset/history.svg diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift index b8175a0d..45b58a21 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift @@ -17,7 +17,7 @@ extension String { return Project.Environment.bundlePrefix } - public static func appBuildVersion(buildVersion: String = "10") -> String { + public static func appBuildVersion(buildVersion: String = "18") -> String { return buildVersion } diff --git a/Projects/Presentation/Profile/Project.swift b/Projects/Presentation/Profile/Project.swift new file mode 100644 index 00000000..8ba48cf7 --- /dev/null +++ b/Projects/Presentation/Profile/Project.swift @@ -0,0 +1,19 @@ +import Foundation +import ProjectDescription +import DependencyPlugin +import ProjectTemplatePlugin +import DependencyPackagePlugin + +let project = Project.makeAppModule( + name: "rofile", + bundleId: .appBundleID(name: ".rofile"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .Domain(implements: .UseCase), + .Shared(implements: .Shared), + .SPM.composableArchitecture, + .SPM.tcaFlow, + ], + sources: ["Sources/**"] +) \ No newline at end of file diff --git a/Projects/Presentation/Profile/Sources/Base.swift b/Projects/Presentation/Profile/Sources/Base.swift new file mode 100644 index 00000000..9ce8c246 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Base.swift @@ -0,0 +1,22 @@ +// +// base.swift +// DDDAttendance. +// +// Created by Roy on 2026-06-08 +// Copyright © 2026 DDD , Ltd., All rights reserved. +// + +import SwiftUI + +struct BaseView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundColor(.accentColor) + Text("Hello, world!") + } + .padding() + } +} + diff --git a/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift b/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift new file mode 100644 index 00000000..d693ae76 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift @@ -0,0 +1,80 @@ +// +// PointHistorySkeletonView.swift +// Profile +// +// 포인트 내역 로딩 스켈레톤 — 라이트(beige) 배경 shimmer. +// + +import SwiftUI + +import DesignSystem + +struct PointHistorySkeletonView: View { + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 16) { + ForEach(0 ..< 8, id: \.self) { _ in + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + block(width: 80, height: 14) + block(width: 64, height: 12) + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 8) { + block(width: 48, height: 14) + block(width: 28, height: 12) + } + } + .padding(16) + .frame(maxWidth: .infinity) + .background(Color.beige50) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.beige600, lineWidth: 1) + ) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + } + + @ViewBuilder + private func block(width: CGFloat, height: CGFloat) -> some View { + LightSkeletonBlock(cornerRadius: 4) + .frame(width: width, height: height) + } +} + +/// 라이트 배경용 skeleton 블록 (옅은 회색 base + 흰색 shimmer). +private struct LightSkeletonBlock: View { + let cornerRadius: CGFloat + + @State private var phase: CGFloat = -1 + + private let baseColor = Color.black.opacity(0.06) + private let shimmerColor = Color.white.opacity(0.55) + + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(baseColor) + .overlay { + LinearGradient( + stops: [ + .init(color: shimmerColor.opacity(0), location: 0), + .init(color: shimmerColor, location: 0.5), + .init(color: shimmerColor.opacity(0), location: 1), + ], + startPoint: UnitPoint(x: phase, y: 0.5), + endPoint: UnitPoint(x: phase + 1, y: 0.5) + ) + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .onAppear { + withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { + phase = 2 + } + } + } +} diff --git a/Projects/Presentation/Profile/Tests/Sources/rofileTests.swift b/Projects/Presentation/Profile/Tests/Sources/rofileTests.swift new file mode 100644 index 00000000..67e89aef --- /dev/null +++ b/Projects/Presentation/Profile/Tests/Sources/rofileTests.swift @@ -0,0 +1,27 @@ +// +// rofileTests.swift +// Presentation.rofileTests +// +// Created by Roy on 2026-06-08. +// + +import Testing +@testable import rofile + +struct rofileTests { + + @Test + func rofileExample() { + // This is an example of a test case. + #expect(true) + } + + @Test + func rofileLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } + +} + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/history.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/history.imageset/Contents.json new file mode 100644 index 00000000..44dcee46 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/history.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "history.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/history.imageset/history.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/history.imageset/history.svg new file mode 100644 index 00000000..7feec141 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/history.imageset/history.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 3949fc99..f4d512c4 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -35,6 +35,48 @@ platform :ios do in_house: false ) + # 빌드 번호 자동 증가 — TestFlight 최신 빌드 번호 + 1 + begin + current_build_number = latest_testflight_build_number( + app_identifier: BUNDLE_ID, + platform: "ios" + ) + puts "📱 TestFlight 최신 빌드 번호: #{current_build_number}" + rescue => e + puts "⚠️ TestFlight 빌드 번호 조회 실패, 기본값 1 사용: #{e.message}" + current_build_number = 1 + end + + begin + live_build_number = app_store_build_number( + app_identifier: BUNDLE_ID, + platform: "ios" + ) + puts "🏪 App Store 라이브 빌드 번호: #{live_build_number}" + current_build_number = [current_build_number, live_build_number].max + rescue => e + puts "⚠️ App Store 빌드 번호 조회 실패: #{e.message}" + end + + new_build_number = current_build_number + 1 + puts "🔢 새로운 빌드 번호: #{new_build_number}" + + # Extension+String.swift 의 빌드 번호 갱신 (tuist generate 시 CFBundleVersion 으로 반영) + extension_file_path = "../Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift" + extension_content = File.read(extension_file_path) + extension_content = extension_content.gsub( + /public static func appBuildVersion\(buildVersion: String = "\d+"\) -> String \{/, + "public static func appBuildVersion(buildVersion: String = \"#{new_build_number}\") -> String {" + ) + File.write(extension_file_path, extension_content) + puts "✅ 빌드 번호를 #{new_build_number}로 업데이트 완료" + + # 갱신된 빌드 번호 반영을 위해 Tuist 강제 재생성 + Dir.chdir("..") do + puts "🔧 Tuist workspace 재생성 중..." + sh("tuist generate --no-open") + puts "✅ Tuist workspace 재생성 완료" + end match( type: "appstore", From 301e1ab714379ee69bc687a751bbb8098dd27547 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 01:03:57 +0900 Subject: [PATCH 05/39] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=E2=80=94=20=EB=A9=94=EC=9D=B8/=EC=84=A4?= =?UTF-8?q?=EC=A0=95/=ED=8F=AC=EC=9D=B8=ED=8A=B8=EB=82=B4=EC=97=AD=20+=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=C2=B7=ED=83=88=ED=87=B4?= =?UTF-8?q?=C2=B7=EC=A3=BC=EC=A0=9C=EC=A0=9C=EC=95=88=20=ED=8C=9D=EC=97=85?= =?UTF-8?q?=20+=20=EC=9B=B9=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /me/mypage, /me/credits/history 연동 (Clean Architecture 전 레이어) - 마이페이지 메인: 프로필 카드/포인트 충전/철학자 유형/메뉴 + 스켈레톤 - 포인트 내역: offset 페이지네이션 + 스켈레톤, 히스토리 아이콘 → 주제 제안 팝업 - 설정: 로그아웃/탈퇴(AuthUseCase + Keychain clear → 로그인 복귀), 약관/개인정보 웹뷰 - CustomAlert .logout/.withdraw/.suggestTopic 스타일 + 공통 팝업 헬퍼 추출 - PickeNavigationBar centerTitle 지원, 푸시 화면 toolbar(.hidden) nav/tab - Date/금액 포맷 Utill·Entity 분리, 색상 .foregroundStyle(.token) 단축형 정리 - MainTab myPage → ProfileCoordinator, 세션 종료 → App presentAuth #9 #10 #13 #15 #16 --- .../TargetDependency+Module/Modules.swift | 1 + Projects/App/Sources/Di/DiRegister.swift | 2 +- Projects/App/Sources/Reducer/AppReducer.swift | 117 ++++---- .../Data/API/Sources/Profile/ProfileAPI.swift | 20 ++ .../Profile/DTO/CreditHistoryDataDTO.swift | 24 ++ .../Sources/Profile/DTO/MyPageDataDTO.swift | 39 +++ .../Mapper/CreditHistoryDataDTO+.swift | 37 +++ .../Profile/Mapper/MyPageDataDTO+.swift | 52 ++++ .../Profile/ProfileRepositoryImpl.swift | 55 ++++ .../Profile/CreditHistoryQueryRequest.swift | 21 ++ .../Sources/Profile/ProfileService.swift | 54 ++++ .../DefaultProfileRepositoryImpl.swift | 43 +++ .../Sources/Profile/ProfileInterface.swift | 35 +++ .../Entity/Sources/Error/ProfileError.swift | 44 +++ .../Sources/Profile/CreditHistory.swift | 72 +++++ .../Entity/Sources/Profile/MyPage.swift | 95 +++++++ .../Sources/Profile/ProfileUseCase.swift | 41 +++ .../Battle/Sources/Main/View/BattleView.swift | 2 +- Projects/Presentation/MainTab/Project.swift | 3 +- .../Sources/Reducer/MainTabCoordinator.swift | 36 ++- .../MainTab/Sources/View/MainTabView.swift | 3 +- Projects/Presentation/Profile/Project.swift | 7 +- .../Presentation/Profile/Sources/Base.swift | 22 -- .../Reducer/ProfileCoordinator.swift | 146 ++++++++++ .../View/ProfileCoordinatorView.swift | 35 +++ .../Sources/Main/Reducer/ProfileFeature.swift | 227 ++++++++++++++++ .../View/Components/ProfileSkeletonView.swift | 95 +++++++ .../Sources/Main/View/ProfileView.swift | 255 ++++++++++++++++++ .../Reducer/PointHistoryFeature.swift | 211 +++++++++++++++ .../Components/PointHistorySkeletonView.swift | 2 +- .../PointHistory/View/PointHistoryView.swift | 138 ++++++++++ .../Settings/Reducer/SettingsFeature.swift | 252 +++++++++++++++++ .../Sources/Settings/View/SettingsView.swift | 80 ++++++ .../{rofileTests.swift => ProfileTests.swift} | 0 .../Web/Sources/View/WebView.swift | 6 +- .../Sources/Image/ImageAsset.swift | 3 + .../Alert/CustomPopup/CustomAlertState.swift | 36 +++ .../CustomConfirmationPopupView.swift | 102 ++++++- .../UI/Navigaion/PickeNavigationBar.swift | 14 +- .../Utill/Sources/Extension/Date+.swift | 30 +++ 40 files changed, 2358 insertions(+), 99 deletions(-) create mode 100644 Projects/Data/API/Sources/Profile/ProfileAPI.swift create mode 100644 Projects/Data/Model/Sources/Profile/DTO/CreditHistoryDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Profile/DTO/MyPageDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Profile/Mapper/CreditHistoryDataDTO+.swift create mode 100644 Projects/Data/Model/Sources/Profile/Mapper/MyPageDataDTO+.swift create mode 100644 Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift create mode 100644 Projects/Data/Service/Sources/Profile/CreditHistoryQueryRequest.swift create mode 100644 Projects/Data/Service/Sources/Profile/ProfileService.swift create mode 100644 Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift create mode 100644 Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift create mode 100644 Projects/Domain/Entity/Sources/Error/ProfileError.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/CreditHistory.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/MyPage.swift create mode 100644 Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift delete mode 100644 Projects/Presentation/Profile/Sources/Base.swift create mode 100644 Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift create mode 100644 Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift create mode 100644 Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift create mode 100644 Projects/Presentation/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift create mode 100644 Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift create mode 100644 Projects/Presentation/Profile/Sources/PointHistory/Reducer/PointHistoryFeature.swift create mode 100644 Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift create mode 100644 Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift create mode 100644 Projects/Presentation/Profile/Sources/Settings/View/SettingsView.swift rename Projects/Presentation/Profile/Tests/Sources/{rofileTests.swift => ProfileTests.swift} (100%) create mode 100644 Projects/Shared/Utill/Sources/Extension/Date+.swift diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index 22c142ba..79f8a1a5 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -28,6 +28,7 @@ public extension ModulePath { case Hifi case Web case Battle + case Profile public static let name: String = "Presentation" diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index 92f4600e..ccffceb3 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -41,7 +41,7 @@ public final class AppDIManager: Sendable { .register { PerspectiveRepositoryImpl() as PerspectiveInterface } .register { SearchRepositoryImpl() as SearchInterface } .register { AudioPlayerRepositoryImpl() as AudioPlayerInterface } -// .register { ProfileRepositoryImpl() as ProfileInterface } + .register { ProfileRepositoryImpl() as ProfileInterface } // .register { AppUpdateRepositoryImpl() as AppUpdateInterface } // 🔐 OAuth Provider 계층 (PFW 조합 패턴) diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index a8fc4724..d43f7149 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -13,18 +13,17 @@ import Presentation @Reducer public struct AppReducer: Sendable { public init() {} - + @ObservableState public enum State { case splash(SplashFeature.State) case auth(AuthCoordinator.State) case mainTab(MainTabCoordinator.State) - - + public init() { self = .splash(SplashFeature.State()) } - + // Animation identifier for SwiftUI transitions var animationID: String { switch self { @@ -34,8 +33,9 @@ public struct AppReducer: Sendable { } } } - - //MARK: - Action + + // MARK: - Action + public enum Action: ViewAction { case view(View) case async(AsyncAction) @@ -43,55 +43,54 @@ public struct AppReducer: Sendable { case navigation(NavigationAction) case scope(ScopeAction) } - + @CasePathable public enum View { case presentView case presentRoot case presentAuth - } - - //MARK: - 앱내에서 사용하는 액션 + + // MARK: - 앱내에서 사용하는 액션 + public enum InnerAction: Equatable { case completeAuthTransition case completeMainTabTransition } - - //MARK: - 비동기 처리 액션 + + // MARK: - 비동기 처리 액션 + public enum AsyncAction: Equatable { case startNotificationListener case refreshTokenExpired } - - //MARK: - 네비게이션 연결 액션 - public enum NavigationAction: Equatable { - - } - - //MARK: - 스코프 액션 + + // MARK: - 네비게이션 연결 액션 + + public enum NavigationAction: Equatable {} + + // MARK: - 스코프 액션 + @CasePathable public enum ScopeAction { case splash(SplashFeature.Action) case auth(AuthCoordinator.Action) case mainTab(MainTabCoordinator.Action) - } - + @Dependency(\.continuousClock) var clock - + // 🎯 PFW 패턴: 강타입 최소 CancelID (3개로 축소) private enum CancelID: Hashable { case coordinator(CoordinatorType) case transition case refreshTokenListener - + enum CoordinatorType: Hashable { - case auth } } - + // 🎯 PFW 패턴: 최소한의 핵심 취소 (3개만) private func cancelAllCoordinatorEffects() -> Effect { return .merge([ @@ -99,11 +98,11 @@ public struct AppReducer: Sendable { // .cancel(id: CancelID.coordinator(.staff)), // .cancel(id: CancelID.coordinator(.member)), .cancel(id: CancelID.coordinator(.auth)), - + // ]) } - + // 🎯 PFW 패턴: 상태 변경 전에 effect 취소를 먼저 완료 private func startTransition(_ action: InnerAction) -> Effect { .concatenate( @@ -113,26 +112,26 @@ public struct AppReducer: Sendable { ) .cancellable(id: CancelID.transition, cancelInFlight: true) } - + // 제거됨: PFW 권장사항에 따라 단순화 - + public var body: some ReducerOf { // 🔥 TCA 해결책 4: Reduce를 ifCaseLet보다 먼저 배치하여 액션 필터링 우선 처리 Reduce { state, action in switch action { - case .view(let viewAction): + case let .view(viewAction): return handleViewAction(state: &state, action: viewAction) - - case .inner(let innerAction): + + case let .inner(innerAction): return handleInnerAction(state: &state, action: innerAction) - - case .async(let asyncAction): + + case let .async(asyncAction): return handleAsyncAction(state: &state, action: asyncAction) - - case .navigation(let navigationAction): + + case let .navigation(navigationAction): return handleNavigationAction(state: &state, action: navigationAction) - - case .scope(let scopeAction): + + case let .scope(scopeAction): // 🎯 PFW 패턴: 단순한 위임 - 복잡한 검증은 handleScopeAction에서 return handleScopeAction(state: &state, action: scopeAction) } @@ -148,9 +147,9 @@ public struct AppReducer: Sendable { MainTabCoordinator() } } - + private func handleViewAction( - state: inout State, + state _: inout State, action: View ) -> Effect { switch action { @@ -158,31 +157,29 @@ public struct AppReducer: Sendable { return .run { send in await send(.scope(.splash(.view(.onAppear)))) } - + case .presentRoot: return startTransition(.completeMainTabTransition) - + case .presentAuth: return startTransition(.completeAuthTransition) - - } } - + private func handleAsyncAction( - state: inout State, + state _: inout State, action: AsyncAction ) -> Effect { switch action { case .startNotificationListener: return setupRefreshTokenExpiredListener() .cancellable(id: CancelID.refreshTokenListener, cancelInFlight: true) - + case .refreshTokenExpired: return startTransition(.completeAuthTransition) } } - + private func handleInnerAction( state: inout State, action: InnerAction @@ -191,21 +188,20 @@ public struct AppReducer: Sendable { case .completeAuthTransition: state = .auth(.init()) return .none - + case .completeMainTabTransition: state = .mainTab(.init()) return .none - } } - + private func handleNavigationAction( - state: inout State, - action: NavigationAction + state _: inout State, + action _: NavigationAction ) -> Effect { return .none } - + // 🎯 PFW 철학: 단순하고 조합 가능한 상태 검증 private func isValidAction( _ action: ScopeAction, @@ -231,7 +227,7 @@ public struct AppReducer: Sendable { // 🎯 PFW 패턴: 단순한 네비게이션 처리 return handleScopeNavigation(action: action) } - + // 🎯 PFW 패턴: 네비게이션 로직 분리 private func handleScopeNavigation(action: ScopeAction) -> Effect { switch action { @@ -253,18 +249,20 @@ public struct AppReducer: Sendable { case .auth(.navigation(.presentMainTab)): return .send(.view(.presentRoot)) + // 로그아웃/탈퇴 → 로그인 화면으로 복귀 + case .mainTab(.delegate(.sessionEnded)): + return .send(.view(.presentAuth)) + default: return .none } } - - + private func isSplashState(_ state: State) -> Bool { guard case .splash = state else { return false } return true } - - + private func setupRefreshTokenExpiredListener() -> Effect { return .publisher { NotificationCenter.default @@ -273,5 +271,4 @@ public struct AppReducer: Sendable { } .cancellable(id: CancelID.refreshTokenListener, cancelInFlight: true) } - } diff --git a/Projects/Data/API/Sources/Profile/ProfileAPI.swift b/Projects/Data/API/Sources/Profile/ProfileAPI.swift new file mode 100644 index 00000000..24272958 --- /dev/null +++ b/Projects/Data/API/Sources/Profile/ProfileAPI.swift @@ -0,0 +1,20 @@ +// +// ProfileAPI.swift +// API +// + +import Foundation + +public enum ProfileAPI { + case mypage + case creditsHistory + + public var description: String { + switch self { + case .mypage: + return "mypage" + case .creditsHistory: + return "credits/history" + } + } +} diff --git a/Projects/Data/Model/Sources/Profile/DTO/CreditHistoryDataDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/CreditHistoryDataDTO.swift new file mode 100644 index 00000000..7dd96b5c --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/DTO/CreditHistoryDataDTO.swift @@ -0,0 +1,24 @@ +// +// CreditHistoryDataDTO.swift +// Model +// +// `GET /api/v1/me/credits/history` 응답 DTO. +// + +import Foundation + +public struct CreditHistoryDataDTO: Decodable { + public let items: [CreditHistoryItemDTO]? + public let nextOffset: Int? + public let hasNext: Bool? +} + +public struct CreditHistoryItemDTO: Decodable { + public let id: Int? + public let creditType: String? + public let amount: Int? + public let referenceId: Int? + public let createdAt: String? +} + +public typealias CreditHistoryResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Profile/DTO/MyPageDataDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/MyPageDataDTO.swift new file mode 100644 index 00000000..a597c5de --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/DTO/MyPageDataDTO.swift @@ -0,0 +1,39 @@ +// +// MyPageDataDTO.swift +// Model +// +// `GET /api/v1/me/mypage` 응답 DTO. +// + +import Foundation + +public struct MyPageDataDTO: Decodable { + public let profile: MyProfileDTO + public let philosopher: MyPhilosopherDTO + public let tier: MyTierDTO +} + +public struct MyProfileDTO: Decodable { + public let userTag: String? + public let nickname: String? + public let characterType: String? + public let characterLabel: String? + public let characterImageUrl: String? + public let mannerTemperature: Double? +} + +public struct MyPhilosopherDTO: Decodable { + public let philosopherType: String? + public let philosopherLabel: String? + public let typeName: String? + public let description: String? + public let imageUrl: String? +} + +public struct MyTierDTO: Decodable { + public let tierCode: String? + public let tierLabel: String? + public let currentPoint: Int? +} + +public typealias MyPageResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Profile/Mapper/CreditHistoryDataDTO+.swift b/Projects/Data/Model/Sources/Profile/Mapper/CreditHistoryDataDTO+.swift new file mode 100644 index 00000000..240cdd1d --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/Mapper/CreditHistoryDataDTO+.swift @@ -0,0 +1,37 @@ +// +// CreditHistoryDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension CreditHistoryDataDTO { + func toDomain() -> CreditHistoryPage { + CreditHistoryPage( + items: (items ?? []).map { $0.toDomain() }, + nextOffset: nextOffset ?? 0, + hasNext: hasNext ?? false + ) + } +} + +public extension CreditHistoryItemDTO { + func toDomain() -> CreditHistoryItem { + CreditHistoryItem( + id: id ?? 0, + creditType: creditType ?? "", + amount: amount ?? 0, + referenceId: referenceId, + createdAt: createdAt.flatMap(Self.parseISO8601) + ) + } + + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} diff --git a/Projects/Data/Model/Sources/Profile/Mapper/MyPageDataDTO+.swift b/Projects/Data/Model/Sources/Profile/Mapper/MyPageDataDTO+.swift new file mode 100644 index 00000000..93d9e513 --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/Mapper/MyPageDataDTO+.swift @@ -0,0 +1,52 @@ +// +// MyPageDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension MyPageDataDTO { + func toDomain() -> MyPage { + MyPage( + profile: profile.toDomain(), + philosopher: philosopher.toDomain(), + tier: tier.toDomain() + ) + } +} + +public extension MyProfileDTO { + func toDomain() -> MyProfile { + MyProfile( + userTag: userTag ?? "", + nickname: nickname ?? "", + characterType: characterType ?? "", + characterLabel: characterLabel ?? "", + characterImageURL: characterImageUrl ?? "", + mannerTemperature: mannerTemperature ?? 0 + ) + } +} + +public extension MyPhilosopherDTO { + func toDomain() -> MyPhilosopher { + MyPhilosopher( + philosopherType: philosopherType ?? "", + philosopherLabel: philosopherLabel ?? "", + typeName: typeName ?? "", + description: description ?? "", + imageURL: imageUrl ?? "" + ) + } +} + +public extension MyTierDTO { + func toDomain() -> MyTier { + MyTier( + tierCode: tierCode ?? "", + tierLabel: tierLabel ?? "", + currentPoint: currentPoint ?? 0 + ) + } +} diff --git a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift new file mode 100644 index 00000000..1479d4c9 --- /dev/null +++ b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift @@ -0,0 +1,55 @@ +// +// ProfileRepositoryImpl.swift +// Repository +// + +import Foundation + +import DomainInterface +import Entity +import Model +import Service + +import LogMacro +import Moya + +@preconcurrency import AsyncMoya + +public final class ProfileRepositoryImpl: ProfileInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } + + public func fetchMyPage() async throws -> MyPage { + let dto: MyPageResponseDTO = try await provider.request(.mypage) + + guard let data = dto.data else { + let message = dto.error?.message ?? "마이페이지 응답이 비어 있습니다" + Log.error("[ProfileRepositoryImpl] empty myPage payload: \(message)") + throw ProfileError.backendError(message) + } + + return data.toDomain() + } + + public func fetchCreditHistory( + offset: Int, + size: Int + ) async throws -> CreditHistoryPage { + let dto: CreditHistoryResponseDTO = try await provider.request( + .creditsHistory(query: CreditHistoryQueryRequest(offset: offset, size: size)) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "크레딧 내역 응답이 비어 있습니다" + Log.error("[ProfileRepositoryImpl] empty creditHistory payload: \(message)") + throw ProfileError.backendError(message) + } + + return data.toDomain() + } +} diff --git a/Projects/Data/Service/Sources/Profile/CreditHistoryQueryRequest.swift b/Projects/Data/Service/Sources/Profile/CreditHistoryQueryRequest.swift new file mode 100644 index 00000000..1e6ba2a8 --- /dev/null +++ b/Projects/Data/Service/Sources/Profile/CreditHistoryQueryRequest.swift @@ -0,0 +1,21 @@ +// +// CreditHistoryQueryRequest.swift +// Service +// +// GET /api/v1/me/credits/history 쿼리 파라미터. +// + +import Foundation + +public struct CreditHistoryQueryRequest: Encodable { + public let offset: Int? + public let size: Int? + + public init( + offset: Int? = nil, + size: Int? = nil + ) { + self.offset = offset + self.size = size + } +} diff --git a/Projects/Data/Service/Sources/Profile/ProfileService.swift b/Projects/Data/Service/Sources/Profile/ProfileService.swift new file mode 100644 index 00000000..aaa203c7 --- /dev/null +++ b/Projects/Data/Service/Sources/Profile/ProfileService.swift @@ -0,0 +1,54 @@ +// +// ProfileService.swift +// Service +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum ProfileService { + case mypage + case creditsHistory(query: CreditHistoryQueryRequest) +} + +extension ProfileService: BaseTargetType { + public typealias Domain = PieckeDomain + + public var domain: PieckeDomain { .profile } + + public var urlPath: String { + switch self { + case .mypage: + return ProfileAPI.mypage.description + case .creditsHistory: + return ProfileAPI.creditsHistory.description + } + } + + public var error: [Int: AsyncMoya.NetworkError]? { nil } + + public var method: Moya.Method { + switch self { + case .mypage, .creditsHistory: + return .get + } + } + + public var parameters: [String: Any]? { + switch self { + case .mypage: + return nil + case let .creditsHistory(query): + guard let dict = query.toDictionary else { return nil } + return dict.isEmpty ? nil : dict + } + } + + public var headers: [String: String]? { + return APIHeader.baseHeader + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift new file mode 100644 index 00000000..61e36fe4 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift @@ -0,0 +1,43 @@ +// +// DefaultProfileRepositoryImpl.swift +// DomainInterface +// + +import Entity +import Foundation + +public struct DefaultProfileRepositoryImpl: ProfileInterface { + public init() {} + + public func fetchMyPage() async throws -> MyPage { + MyPage( + profile: MyProfile( + userTag: "", + nickname: "", + characterType: "", + characterLabel: "", + characterImageURL: "", + mannerTemperature: 0 + ), + philosopher: MyPhilosopher( + philosopherType: "", + philosopherLabel: "", + typeName: "", + description: "", + imageURL: "" + ), + tier: MyTier( + tierCode: "", + tierLabel: "", + currentPoint: 0 + ) + ) + } + + public func fetchCreditHistory( + offset _: Int, + size _: Int + ) async throws -> CreditHistoryPage { + CreditHistoryPage(items: [], nextOffset: 0, hasNext: false) + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift new file mode 100644 index 00000000..dbf62bd9 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift @@ -0,0 +1,35 @@ +// +// ProfileInterface.swift +// DomainInterface +// + +import Entity +import Foundation +import WeaveDI + +public protocol ProfileInterface: Sendable { + func fetchMyPage() async throws -> MyPage + func fetchCreditHistory( + offset: Int, + size: Int + ) async throws -> CreditHistoryPage +} + +public struct ProfileRepositoryDependency: DependencyKey { + public static var liveValue: ProfileInterface { + UnifiedDI.resolve(ProfileInterface.self) ?? DefaultProfileRepositoryImpl() + } + + public static var testValue: ProfileInterface { + UnifiedDI.resolve(ProfileInterface.self) ?? DefaultProfileRepositoryImpl() + } + + public static var previewValue: ProfileInterface = liveValue +} + +public extension DependencyValues { + var profileRepository: ProfileInterface { + get { self[ProfileRepositoryDependency.self] } + set { self[ProfileRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/Entity/Sources/Error/ProfileError.swift b/Projects/Domain/Entity/Sources/Error/ProfileError.swift new file mode 100644 index 00000000..65373856 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Error/ProfileError.swift @@ -0,0 +1,44 @@ +// +// ProfileError.swift +// Entity +// +// 프로필(마이페이지) 도메인 표준 에러. +// + +import Foundation + +public enum ProfileError: LocalizedError, Equatable { + case networkError(String) + case decodingError(String) + case noData + case unauthorized + case backendError(String) + case serverError(Int) + case unknown(String) + + public var errorDescription: String? { + switch self { + case let .networkError(message): + "네트워크 오류: \(message)" + case let .decodingError(message): + "데이터 파싱 오류: \(message)" + case .noData: + "프로필 데이터가 없습니다" + case .unauthorized: + "권한이 없습니다" + case let .backendError(message): + "프로필 처리 실패: \(message)" + case let .serverError(code): + "서버 오류 (코드: \(code))" + case let .unknown(message): + "알 수 없는 오류: \(message)" + } + } +} + +public extension ProfileError { + static func from(_ error: Error) -> ProfileError { + if let profileError = error as? ProfileError { return profileError } + return .unknown(error.localizedDescription) + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/CreditHistory.swift b/Projects/Domain/Entity/Sources/Profile/CreditHistory.swift new file mode 100644 index 00000000..400a7f56 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/CreditHistory.swift @@ -0,0 +1,72 @@ +// +// CreditHistory.swift +// Entity +// +// `GET /api/v1/me/credits/history` 응답 도메인 모델. +// 크레딧(포인트) 지급/사용 내역 + offset 기반 페이지네이션. +// + +import Foundation + +public struct CreditHistoryPage: Equatable { + public let items: [CreditHistoryItem] + public let nextOffset: Int + public let hasNext: Bool + + public init( + items: [CreditHistoryItem], + nextOffset: Int, + hasNext: Bool + ) { + self.items = items + self.nextOffset = nextOffset + self.hasNext = hasNext + } +} + +public struct CreditHistoryItem: Equatable, Identifiable { + public let id: Int + /// 크레딧 유형 코드 (예: `TODAY_CREDIT`). + public let creditType: String + /// 변동 포인트. 양수=적립, 음수=사용. + public let amount: Int + public let referenceId: Int? + public let createdAt: Date? + + public init( + id: Int, + creditType: String, + amount: Int, + referenceId: Int?, + createdAt: Date? + ) { + self.id = id + self.creditType = creditType + self.amount = amount + self.referenceId = referenceId + self.createdAt = createdAt + } + + /// 적립 여부 (양수). + public var isEarned: Bool { amount >= 0 } + + /// 금액 표시 (예: `+ 10P` / `- 5P`). + public var amountText: String { + "\(isEarned ? "+" : "-") \(abs(amount))P" + } + + /// 적립/사용 라벨. + public var statusText: String { isEarned ? "적립" : "사용" } + + /// 크레딧 유형 표시명. + public var title: String { + switch creditType { + case "TODAY_CREDIT", "FREE_CREDIT", "SIGNUP_CREDIT": + return "무료 충전" + case "BATTLE_PARTICIPATION", "BATTLE_VOTE", "BATTLE": + return "배틀 참여" + default: + return isEarned ? "포인트 적립" : "포인트 사용" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/MyPage.swift b/Projects/Domain/Entity/Sources/Profile/MyPage.swift new file mode 100644 index 00000000..5b315c2d --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/MyPage.swift @@ -0,0 +1,95 @@ +// +// MyPage.swift +// Entity +// +// `GET /api/v1/me/mypage` 응답 도메인 모델. +// 프로필 / 철학자 유형 / 티어(포인트) 3개 섹션. +// + +import Foundation + +public struct MyPage: Equatable { + public let profile: MyProfile + public let philosopher: MyPhilosopher + public let tier: MyTier + + public init( + profile: MyProfile, + philosopher: MyPhilosopher, + tier: MyTier + ) { + self.profile = profile + self.philosopher = philosopher + self.tier = tier + } +} + +public struct MyProfile: Equatable { + /// 사용자 코드 (`@` 표기에 사용). + public let userTag: String + public let nickname: String + /// 캐릭터 유형 코드 (예: `OWL`). + public let characterType: String + public let characterLabel: String + public let characterImageURL: String + /// 매너 온도. + public let mannerTemperature: Double + + public init( + userTag: String, + nickname: String, + characterType: String, + characterLabel: String, + characterImageURL: String, + mannerTemperature: Double + ) { + self.userTag = userTag + self.nickname = nickname + self.characterType = characterType + self.characterLabel = characterLabel + self.characterImageURL = characterImageURL + self.mannerTemperature = mannerTemperature + } +} + +public struct MyPhilosopher: Equatable { + /// 철학자 유형 코드 (예: `SOCRATES`). + public let philosopherType: String + public let philosopherLabel: String + /// 유형명 (예: `??형`). + public let typeName: String + public let description: String + public let imageURL: String + + public init( + philosopherType: String, + philosopherLabel: String, + typeName: String, + description: String, + imageURL: String + ) { + self.philosopherType = philosopherType + self.philosopherLabel = philosopherLabel + self.typeName = typeName + self.description = description + self.imageURL = imageURL + } +} + +public struct MyTier: Equatable { + /// 티어 코드 (예: `WANDERER`). + public let tierCode: String + public let tierLabel: String + /// 보유 포인트. + public let currentPoint: Int + + public init( + tierCode: String, + tierLabel: String, + currentPoint: Int + ) { + self.tierCode = tierCode + self.tierLabel = tierLabel + self.currentPoint = currentPoint + } +} diff --git a/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift new file mode 100644 index 00000000..179a5f14 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift @@ -0,0 +1,41 @@ +// +// ProfileUseCase.swift +// UseCase +// + +import Foundation + +import DomainInterface +import Entity + +import ComposableArchitecture + +public struct ProfileUseCaseImpl: ProfileInterface { + @Dependency(\.profileRepository) private var profileRepository + + public init() {} + + public func fetchMyPage() async throws -> MyPage { + return try await profileRepository.fetchMyPage() + } + + public func fetchCreditHistory( + offset: Int, + size: Int + ) async throws -> CreditHistoryPage { + return try await profileRepository.fetchCreditHistory(offset: offset, size: size) + } +} + +extension ProfileUseCaseImpl: DependencyKey { + public static var liveValue = ProfileUseCaseImpl() + public static var testValue = ProfileUseCaseImpl() + public static var previewValue = ProfileUseCaseImpl() +} + +public extension DependencyValues { + var profileUseCase: ProfileUseCaseImpl { + get { self[ProfileUseCaseImpl.self] } + set { self[ProfileUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift b/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift index 349b25aa..dcb53f4d 100644 --- a/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift +++ b/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift @@ -339,7 +339,7 @@ private extension BattleView { .scaledToFit() .frame(width: 135, height: 90) - Text("오늘의 배틀이 없습니다") + Text("아직 빠른 배틀이 선정되지않았어요\n 조금만 기다려주세요!") .pretendardCustomFont(textStyle: .bodyMedium) .foregroundStyle(.beige300) } diff --git a/Projects/Presentation/MainTab/Project.swift b/Projects/Presentation/MainTab/Project.swift index 4010a39d..188eb59f 100644 --- a/Projects/Presentation/MainTab/Project.swift +++ b/Projects/Presentation/MainTab/Project.swift @@ -16,7 +16,8 @@ let project = Project.makeAppModule( .Shared(implements: .DesignSystem), .Presentation(implements: .Home), .Presentation(implements: .Hifi), - .Presentation(implements: .Battle) + .Presentation(implements: .Battle), + .Presentation(implements: .Profile) ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift index 1c2c4022..5e596758 100644 --- a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift +++ b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift @@ -14,6 +14,7 @@ import Battle import DesignSystem import Hifi import Home +import Profile /// 픽케 메인 탭 코디네이터. /// 모든 탭은 우선 HomeCoordinator 로 채워두고, 각 기능 (Explore / QuickBattle / MyPage) @@ -56,7 +57,7 @@ public struct MainTabCoordinator { public var homeState: HomeCoordinator.State public var exploreState: HifiCoordinator.State public var quickBattleState: BattleCoordinator.State - public var myPageState: HomeCoordinator.State + public var myPageState: ProfileCoordinator.State public init(selectedTab: Int = Tab.home.rawValue) { self.selectedTab = selectedTab @@ -74,7 +75,13 @@ public struct MainTabCoordinator { case home(HomeCoordinator.Action) case explore(HifiCoordinator.Action) case quickBattle(BattleCoordinator.Action) - case myPage(HomeCoordinator.Action) + case myPage(ProfileCoordinator.Action) + case delegate(DelegateAction) + } + + public enum DelegateAction: Equatable { + /// 로그아웃/탈퇴 완료 → 루트(App)에서 로그인 화면으로 전환. + case sessionEnded } public var body: some ReducerOf { @@ -88,7 +95,7 @@ public struct MainTabCoordinator { BattleCoordinator() } Scope(state: \.myPageState, action: \.myPage) { - HomeCoordinator() + ProfileCoordinator() } Reduce { state, action in @@ -114,15 +121,38 @@ public struct MainTabCoordinator { state.selectedTab = state.previousTab return .none + // 마이 상단 백탭 → 직전 탭으로 복귀 + case .myPage(.router(.routeAction(_, action: .profile(.delegate(.backToHome))))): + state.selectedTab = state.previousTab + return .none + + // 설정 → 로그아웃/탈퇴 완료 → App 으로 세션 종료 전파 + case .myPage(.router(.routeAction(_, action: .settings(.delegate(.sessionEnded))))): + return .send(.delegate(.sessionEnded)) + // 홈 "더보기" → 탐색 탭으로 이동 case .home(.router(.routeAction(_, action: .home(.delegate(.moveToExplore))))): if state.selectedTab != Tab.explore.rawValue { state.previousTab = state.selectedTab } state.selectedTab = Tab.explore.rawValue return .none + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + case .home, .explore, .quickBattle, .myPage: return .none } } } + + /// delegate 는 부모(App)가 관찰 — MainTab 은 발행만 하고 여기서는 가로채지 않는다. + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .sessionEnded: + return .none + } + } } diff --git a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift index f6f8b0aa..dd1c8ab9 100644 --- a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift +++ b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift @@ -12,6 +12,7 @@ import Battle import DesignSystem import Hifi import Home +import Profile import TCAFlow import ComposableArchitecture @@ -129,7 +130,7 @@ extension MainTabView { ) case .myPage: - HomeCoordinatorView( + ProfileCoordinatorView( store: store.scope(state: \.myPageState, action: \.myPage) ) diff --git a/Projects/Presentation/Profile/Project.swift b/Projects/Presentation/Profile/Project.swift index 8ba48cf7..80ac0f8a 100644 --- a/Projects/Presentation/Profile/Project.swift +++ b/Projects/Presentation/Profile/Project.swift @@ -5,8 +5,8 @@ import ProjectTemplatePlugin import DependencyPackagePlugin let project = Project.makeAppModule( - name: "rofile", - bundleId: .appBundleID(name: ".rofile"), + name: "Profile", + bundleId: .appBundleID(name: ".Profile"), product: .staticFramework, settings: .settings(), dependencies: [ @@ -14,6 +14,7 @@ let project = Project.makeAppModule( .Shared(implements: .Shared), .SPM.composableArchitecture, .SPM.tcaFlow, + .Presentation(implements: .Web) ], sources: ["Sources/**"] -) \ No newline at end of file +) diff --git a/Projects/Presentation/Profile/Sources/Base.swift b/Projects/Presentation/Profile/Sources/Base.swift deleted file mode 100644 index 9ce8c246..00000000 --- a/Projects/Presentation/Profile/Sources/Base.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// base.swift -// DDDAttendance. -// -// Created by Roy on 2026-06-08 -// Copyright © 2026 DDD , Ltd., All rights reserved. -// - -import SwiftUI - -struct BaseView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("Hello, world!") - } - .padding() - } -} - diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift new file mode 100644 index 00000000..e5a1fb73 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift @@ -0,0 +1,146 @@ +// +// ProfileCoordinator.swift +// Profile +// +// 마이 탭 코디네이터. 루트는 ProfileFeature, 편집/세부 화면은 추후 push. +// + +import Foundation + +import ComposableArchitecture +import Entity +import TCAFlow +import Web + +@FlowCoordinator(screen: "ProfileScreen", navigation: true) +public struct ProfileCoordinator { + public init() {} + + @ObservableState + public struct State: Equatable { + public var routes: [Route] + + public init() { + routes = [.root(.profile(.init()), embedInNavigationView: true)] + } + } + + @CasePathable + public enum Action { + case router(IndexedRouterActionOf) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case navigation(NavigationAction) + } + + @CasePathable + public enum View { + case backAction + case backToRootAction + } + + public enum AsyncAction: Equatable {} + public enum InnerAction: Equatable {} + public enum NavigationAction: Equatable {} + + func handleRoute( + state: inout State, + action: Action + ) -> Effect { + switch action { + case let .router(routeAction): + routerAction(state: &state, action: routeAction) + case let .view(viewAction): + handleViewAction(state: &state, action: viewAction) + case .async, .inner, .navigation: + .none + } + } +} + +extension ProfileCoordinator { + private func routerAction( + state: inout State, + action: IndexedRouterActionOf + ) -> Effect { + switch action { + // 프로필 편집 / 포인트 충전 / 철학자 / 메뉴 — 세부 화면 추가 시 분기 확장. + case .routeAction(_, action: .profile(.delegate(.editProfile))): + return .none + + // 포인트(크레딧) 충전 영역 탭 → 포인트 내역 화면 진입. + case .routeAction(_, action: .profile(.delegate(.chargePoint))): + state.routes.push(.pointHistory(.init())) + return .none + + // 포인트 내역 상단 백탭 → 뒤로. + case .routeAction(_, action: .pointHistory(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case .routeAction(_, action: .profile(.delegate(.openPhilosopher))): + return .none + + case .routeAction(_, action: .profile(.delegate(.openNotification))): + return .none + + // 설정 아이콘 → 설정 화면 진입. + case .routeAction(_, action: .profile(.delegate(.openSettings))): + state.routes.push(.settings(.init())) + return .none + + // 설정 상단 백탭 → 뒤로. + case .routeAction(_, action: .settings(.delegate(.dismiss))): + return .send(.view(.backAction)) + + // 개인정보 처리방침 → 웹뷰. + case .routeAction(_, action: .settings(.delegate(.openPrivacy))): + state.routes.push(.web(.init(url: TermsDocument.privacy.urlString))) + return .none + + // 서비스 약관 → 웹뷰. + case .routeAction(_, action: .settings(.delegate(.openTerms))): + state.routes.push(.web(.init(url: TermsDocument.service.urlString))) + return .none + + // 웹뷰 뒤로. + case .routeAction(_, action: .web(.backToRoot)): + return .send(.view(.backAction)) + + case .routeAction(_, action: .profile(.delegate(.menuSelected))): + return .none + + default: + return .none + } + } + + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backAction: + state.routes.goBack() + return .none + case .backToRootAction: + state.routes.goBackToRoot() + return .none + } + } +} + +// swiftformat:disable extensionAccessControl +extension ProfileCoordinator { + @Reducer + public enum ProfileScreen { + case profile(ProfileFeature) + case pointHistory(PointHistoryFeature) + case settings(SettingsFeature) + case web(WebReducer) + } +} + +// swiftformat:enable extensionAccessControl + +extension ProfileCoordinator.ProfileScreen.State: Equatable {} diff --git a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift new file mode 100644 index 00000000..08b990c3 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift @@ -0,0 +1,35 @@ +// +// ProfileCoordinatorView.swift +// Profile +// + +import Foundation + +import SwiftUI + +import ComposableArchitecture +import TCAFlow +import Web + +public struct ProfileCoordinatorView: View { + @Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + TCAFlowRouter(store.scope(state: \.routes, action: \.router)) { screen in + switch screen.case { + case let .profile(profileStore): + ProfileView(store: profileStore) + case let .pointHistory(pointHistoryStore): + PointHistoryView(store: pointHistoryStore) + case let .settings(settingsStore): + SettingsView(store: settingsStore) + case let .web(webStore): + WebView(store: webStore) + } + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift new file mode 100644 index 00000000..9adc10f7 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift @@ -0,0 +1,227 @@ +// +// ProfileFeature.swift +// Profile +// +// 마이페이지 루트 기능 — picke.pen `마이페이지_잠금`. +// 프로필 카드(닉네임/잠금) + 포인트 충전 + 나의 철학자 유형 + 메뉴 리스트. +// 프로필/포인트 조회 API 연동 전까지는 기본(목) 값 노출. +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro +import UseCase + +@Reducer +public struct ProfileFeature { + public init() {} + + /// 마이페이지 메뉴 항목 — picke.pen 기준. + public enum MenuItem: String, CaseIterable, Equatable, Identifiable { + case battleHistory = "내 배틀 기록" + case contentActivity = "내 콘텐츠 활동" + case noticeEvent = "공지방 · 이벤트" + + public var id: String { rawValue } + } + + @ObservableState + public struct State: Equatable { + public var isLoading: Bool = false + /// 닉네임. + public var nickname: String = "사색하는 고양이" + /// 사용자 코드 (앞에 `@` 표기). + public var userCode: String = "user_code" + /// 프로필 잠금 여부 — 닉네임 옆 자물쇠 노출. + public var isLocked: Bool = true + /// 보유 포인트. + public var point: Int = 240 + /// 나의 철학자 유형 — 미확정 시 `??형`. + public var philosopherType: String? + /// 프로필 이미지 URL (없으면 기본 아바타). + public var profileImageURL: String? + /// 메뉴 목록. + public var menuItems: [MenuItem] = MenuItem.allCases + + public init() {} + + /// 표시용 철학자 유형. + public var philosopherDisplay: String { philosopherType ?? "??형" } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backTapped + case notificationTapped + case settingsTapped + case profileTapped + case chargePointTapped + case philosopherTapped + case menuTapped(MenuItem) + } + + public enum AsyncAction: Equatable { + case fetchProfile + } + + public enum InnerAction: Equatable { + case myPageResponse(Result) + } + + public enum DelegateAction: Equatable { + /// 상단 백탭 → 직전 탭으로 복귀. + case backToHome + /// 알림함 이동. + case openNotification + /// 설정 화면 이동. + case openSettings + /// 프로필 카드 탭 → 편집. + case editProfile + /// 포인트 충전. + case chargePoint + /// 나의 철학자 유형 상세. + case openPhilosopher + /// 메뉴 항목 선택. + case menuSelected(MenuItem) + } + + nonisolated enum CancelID: Hashable { + case fetchProfile + } + + @Dependency(\.profileUseCase) private var profileUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension ProfileFeature { + private func handleViewAction( + state _: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + return .send(.async(.fetchProfile)) + + case .backTapped: + return .send(.delegate(.backToHome)) + + case .notificationTapped: + return .send(.delegate(.openNotification)) + + case .settingsTapped: + return .send(.delegate(.openSettings)) + + case .profileTapped: + return .send(.delegate(.editProfile)) + + case .chargePointTapped: + return .send(.delegate(.chargePoint)) + + case .philosopherTapped: + return .send(.delegate(.openPhilosopher)) + + case let .menuTapped(item): + return .send(.delegate(.menuSelected(item))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetchProfile: + state.isLoading = true + return .run { [useCase = profileUseCase] send in + let result = await Result { + try await useCase.fetchMyPage() + } + .mapError(ProfileError.from) + return await send(.inner(.myPageResponse(result))) + } + .cancellable(id: CancelID.fetchProfile, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .myPageResponse(result): + state.isLoading = false + switch result { + case let .success(myPage): + state.nickname = myPage.profile.nickname + state.userCode = myPage.profile.userTag + state.point = myPage.tier.currentPoint + state.philosopherType = myPage.philosopher.typeName.isEmpty ? nil : myPage.philosopher.typeName + state.profileImageURL = myPage.profile.characterImageURL.isEmpty ? nil : myPage.profile.characterImageURL + case let .failure(error): + Log.error("[ProfileFeature] fetchMyPage failed: \(error.localizedDescription)") + } + return .none + } + } + + /// delegate 는 부모(ProfileCoordinator / MainTab)가 처리 — Feature 는 발행만 한다. + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .backToHome: + return .none + + case .openNotification: + return .none + + case .openSettings: + return .none + + case .editProfile: + return .none + + case .chargePoint: + return .none + + case .openPhilosopher: + return .none + + case .menuSelected: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift b/Projects/Presentation/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift new file mode 100644 index 00000000..d8c0ba06 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift @@ -0,0 +1,95 @@ +// +// ProfileSkeletonView.swift +// Profile +// +// 마이페이지 로딩 스켈레톤 — 라이트(beige) 배경에 맞춘 옅은 shimmer. +// + +import SwiftUI + +import DesignSystem + +struct ProfileSkeletonView: View { + var body: some View { + VStack(spacing: 20) { + // 프로필 카드 자리 + HStack(spacing: 12) { + block(width: 52, height: 52, radius: 26) + VStack(alignment: .leading, spacing: 8) { + block(width: 140, height: 16) + block(width: 80, height: 13) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + + VStack(spacing: 16) { + block(maxWidth: true, height: 64, radius: 8) // 포인트 충전 버튼 + block(maxWidth: true, height: 72, radius: 8) // 나의 철학자 유형 + } + .padding(.horizontal, 16) + + // 메뉴 리스트 자리 + VStack(spacing: 0) { + ForEach(0 ..< 3, id: \.self) { _ in + HStack { + block(width: 100, height: 16) + Spacer() + } + .padding(.vertical, 20) + .overlay(alignment: .bottom) { + Rectangle().fill(.beige600).frame(height: 1) + } + } + } + .padding(.horizontal, 16) + + Spacer(minLength: 0) + } + } + + @ViewBuilder + private func block( + width: CGFloat? = nil, + maxWidth: Bool = false, + height: CGFloat, + radius: CGFloat = 4 + ) -> some View { + LightSkeletonBlock(cornerRadius: radius) + .frame(width: width) + .frame(maxWidth: maxWidth ? .infinity : nil) + .frame(height: height) + } +} + +/// 라이트 배경용 skeleton 블록 (옅은 회색 base + 흰색 shimmer). +private struct LightSkeletonBlock: View { + let cornerRadius: CGFloat + + @State private var phase: CGFloat = -1 + + private let baseColor = Color.black.opacity(0.06) + private let shimmerColor = Color.white.opacity(0.55) + + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(baseColor) + .overlay { + LinearGradient( + stops: [ + .init(color: shimmerColor.opacity(0), location: 0), + .init(color: shimmerColor, location: 0.5), + .init(color: shimmerColor.opacity(0), location: 1), + ], + startPoint: UnitPoint(x: phase, y: 0.5), + endPoint: UnitPoint(x: phase + 1, y: 0.5) + ) + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .onAppear { + withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { + phase = 2 + } + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift new file mode 100644 index 00000000..b4b5c6f1 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift @@ -0,0 +1,255 @@ +// +// ProfileView.swift +// Profile +// +// 마이페이지 루트 UI — picke.pen `마이페이지_잠금`. +// 프로필 카드 + 포인트 충전 버튼 + 나의 철학자 유형 + 메뉴 리스트. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Kingfisher + +@ViewAction(for: ProfileFeature.self) +public struct ProfileView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + topBar() + + if store.isLoading { + ProfileSkeletonView() + } else { + // xr63n: 카드 그룹 ↔ 메뉴 그룹 gap 20 + VStack(spacing: 20) { + // T3oil: 카드 3개 gap 16, 좌우 16 + VStack(spacing: 16) { + profileCard() + chargeButton() + philosopherCard() + } + .padding(.horizontal, 16) + + // HFFUM: 메뉴 리스트 좌우 16 + menuList() + .padding(.horizontal, 16) + + Spacer(minLength: 0) + } + } + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .onAppear { send(.onAppear) } + } +} + +private extension ProfileView { + // MARK: 상단 바 (알림 / 설정) + + @ViewBuilder + func topBar() -> some View { + HStack(spacing: 6) { + Spacer() + + Button { send(.notificationTapped) } label: { + Image(systemName: "bell") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(.neutral900) + .frame(width: 24, height: 24) + } + + Button { send(.settingsTapped) } label: { + Image(systemName: "gearshape") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(.neutral900) + .frame(width: 24, height: 24) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + + // MARK: 프로필 카드 + + @ViewBuilder + func profileCard() -> some View { + Button { + send(.profileTapped) + } label: { + HStack(spacing: 12) { + avatar() + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 5) { + Text(store.nickname) + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.gray800) + + if store.isLocked { + Image(systemName: "lock.fill") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.gray300) + } + } + + Text("@\(store.userCode)") + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.gray300) + } + + Spacer(minLength: 0) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + func avatar() -> some View { + if let urlString = store.profileImageURL, let url = URL(string: urlString) { + KFImage(url) + .resizable() + .scaledToFill() + .frame(width: 52, height: 52) + .clipShape(Circle()) + } else { + ZStack { + Circle().fill(.beige600) + Image(systemName: "cat.fill") + .font(.system(size: 24)) + .foregroundStyle(.gray300) + } + .frame(width: 52, height: 52) + } + } + + // MARK: 포인트 충전 버튼 + + @ViewBuilder + func chargeButton() -> some View { + Button { + send(.chargePointTapped) + } label: { + HStack(spacing: 6) { + // 포인트 뱃지 + 보유 포인트 + ZStack { + Circle().fill(.secondary300) + Text("P") + .pretendardFont(family: .Bold, size: 11) + .foregroundStyle(.gray800) + } + .frame(width: 24, height: 24) + + Text("내 포인트 \(store.point)") + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.beige50) + + Spacer(minLength: 8) + + Text("무료 충전") + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.gray800) + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(Color.secondary300) + .clipShape(RoundedRectangle(cornerRadius: 2)) + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity) + .background(Color.primary800) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + + // MARK: 나의 철학자 유형 + + @ViewBuilder + func philosopherCard() -> some View { + Button { + send(.philosopherTapped) + } label: { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(.beige200) + Image(systemName: "brain.head.profile") + .font(.system(size: 20)) + .foregroundStyle(.gray300) + } + .frame(width: 40, height: 40) + + VStack(alignment: .leading, spacing: 4) { + Text("나의 철학자 유형") + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.gray300) + + Text(store.philosopherDisplay) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.gray700) + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.neutral900) + } + .padding(16) + .frame(maxWidth: .infinity) + .background(Color.beige400) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.beige600, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + // MARK: 메뉴 리스트 + + @ViewBuilder + func menuList() -> some View { + VStack(spacing: 0) { + ForEach(store.menuItems) { item in + menuRow(item) + } + } + } + + @ViewBuilder + func menuRow(_ item: ProfileFeature.MenuItem) -> some View { + Button { + send(.menuTapped(item)) + } label: { + HStack { + Text(item.rawValue) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.gray800) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.neutral900) + } + .padding(.vertical, 20) + .contentShape(Rectangle()) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } + .buttonStyle(.plain) + } +} diff --git a/Projects/Presentation/Profile/Sources/PointHistory/Reducer/PointHistoryFeature.swift b/Projects/Presentation/Profile/Sources/PointHistory/Reducer/PointHistoryFeature.swift new file mode 100644 index 00000000..938ea6c9 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/PointHistory/Reducer/PointHistoryFeature.swift @@ -0,0 +1,211 @@ +// +// PointHistoryFeature.swift +// Profile +// +// 포인트(크레딧) 내역 — picke.pen `포인트 내역`. +// GET /api/v1/me/credits/history (offset 기반 페이지네이션). +// + +import Foundation + +import ComposableArchitecture +import DesignSystem +import Entity +import LogMacro +import UseCase + +@Reducer +public struct PointHistoryFeature { + public init() {} + + static let pageSize = 20 + + @ObservableState + public struct State: Equatable { + public var isLoading: Bool = false + public var isLoadingMore: Bool = false + public var items: [CreditHistoryItem] = [] + public var nextOffset: Int = 0 + public var hasNext: Bool = false + @Presents public var customAlert: CustomAlertState? + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case scope(ScopeAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backTapped + case reachedBottom + case suggestTopicTapped + } + + public enum AsyncAction: Equatable { + case fetch(reset: Bool) + } + + public enum InnerAction: Equatable { + case historyResponse(Result, reset: Bool) + } + + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) + } + + public enum DelegateAction: Equatable { + case dismiss + /// 주제(배틀) 제안 화면 진입. + case suggestTopic + } + + nonisolated enum CancelID: Hashable { + case fetch + } + + @Dependency(\.profileUseCase) private var profileUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .scope(scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } + } +} + +extension PointHistoryFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard state.items.isEmpty else { return .none } + return .send(.async(.fetch(reset: true))) + + case .backTapped: + return .send(.delegate(.dismiss)) + + case .reachedBottom: + guard state.hasNext, !state.isLoadingMore, !state.isLoading else { return .none } + return .send(.async(.fetch(reset: false))) + + case .suggestTopicTapped: + state.customAlert = .suggestTopic() + return .none + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case let .fetch(reset): + if reset { + state.isLoading = true + } else { + state.isLoadingMore = true + } + let offset = reset ? 0 : state.nextOffset + return .run { [useCase = profileUseCase] send in + let result = await Result { + try await useCase.fetchCreditHistory(offset: offset, size: Self.pageSize) + } + .mapError(ProfileError.from) + return await send(.inner(.historyResponse(result, reset: reset))) + } + .cancellable(id: CancelID.fetch, cancelInFlight: reset) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .historyResponse(result, reset): + state.isLoading = false + state.isLoadingMore = false + switch result { + case let .success(page): + if reset { + state.items = page.items + } else { + state.items.append(contentsOf: page.items) + } + state.nextOffset = page.nextOffset + state.hasNext = page.hasNext + case let .failure(error): + Log.error("[PointHistoryFeature] fetchCreditHistory failed: \(error.localizedDescription)") + } + return .none + } + } + + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case let .customAlert(alertAction): + switch alertAction { + case let .presented(customAlertAction): + switch customAlertAction { + case .confirmTapped: + // 제안하기 → 주제 제안 화면 진입 (추후 연동). + state.customAlert = nil + return .send(.delegate(.suggestTopic)) + case .cancelTapped: + state.customAlert = nil + return .none + } + case .dismiss: + state.customAlert = nil + return .none + } + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + case .suggestTopic: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift b/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift index d693ae76..6bb1fbd7 100644 --- a/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift +++ b/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift @@ -31,7 +31,7 @@ struct PointHistorySkeletonView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(Color.beige600, lineWidth: 1) + .stroke(.beige600, lineWidth: 1) ) } } diff --git a/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift b/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift new file mode 100644 index 00000000..7178561b --- /dev/null +++ b/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift @@ -0,0 +1,138 @@ +// +// PointHistoryView.swift +// Profile +// +// 포인트(크레딧) 내역 UI — picke.pen `포인트 내역`. +// App Bar(백/타이틀) + 내역 리스트(좌: 유형·날짜 / 우: 금액·적립·사용). +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity +import Utill + +@ViewAction(for: PointHistoryFeature.self) +public struct PointHistoryView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + appBar() + + if store.isLoading { + PointHistorySkeletonView() + } else { + content() + } + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + .onAppear { send(.onAppear) } + } +} + +private extension PointHistoryView { + // MARK: App Bar + + @ViewBuilder + func appBar() -> some View { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "포인트 내역") { + // 배틀(주제) 제안 팝업 열기 + Button { send(.suggestTopicTapped) } label: { + Image(asset: .history) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + } + .foregroundStyle(.gray500) + } + + // MARK: 내역 리스트 + + @ViewBuilder + func content() -> some View { + if store.items.isEmpty { + emptyState() + } else { + ScrollView { + LazyVStack(spacing: 16) { + ForEach(store.items) { item in + historyRow(item) + .onAppear { + if item.id == store.items.last?.id { + send(.reachedBottom) + } + } + } + + if store.isLoadingMore { + ProgressView() + .padding(.vertical, 8) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + } + } + + @ViewBuilder + func historyRow(_ item: CreditHistoryItem) -> some View { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text(item.title) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.neutral900) + + Text(item.createdAt.yearMonthDayDot) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + } + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 6) { + Text(item.amountText) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(item.isEarned ? .primary500 : .gray500) + + Text(item.statusText) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + } + } + .padding(16) + .frame(maxWidth: .infinity) + .background(Color.beige50) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.beige600, lineWidth: 1) + ) + } + + @ViewBuilder + func emptyState() -> some View { + VStack(spacing: 8) { + Spacer() + Image(systemName: "tray") + .font(.system(size: 36, weight: .regular)) + .foregroundStyle(.gray300) + Text("포인트 내역이 없습니다") + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.gray300) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift b/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift new file mode 100644 index 00000000..8169ea91 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift @@ -0,0 +1,252 @@ +// +// SettingsFeature.swift +// Profile +// +// 설정 — picke.pen `설정`. +// 메뉴(알림설정/개인정보 처리방침/서비스 약관/로그아웃/회원 탈퇴) + 로그아웃·탈퇴 확인 팝업. +// 팝업은 DesignSystem 의 CustomAlert(.logout/.withdraw) 사용. +// 로그아웃/탈퇴 시 AuthUseCase 호출 → Keychain 초기화 → 로그인 화면 복귀. +// + +import Foundation + +import ComposableArchitecture +import DesignSystem +import Entity +import LogMacro +import UseCase + +@Reducer +public struct SettingsFeature { + public init() {} + + public enum MenuItem: String, CaseIterable, Equatable, Identifiable { + case notification = "알림설정" + case privacy = "개인정보 처리방침" + case terms = "서비스 약관" + case logout = "로그아웃" + case withdraw = "회원 탈퇴" + + public var id: String { rawValue } + } + + /// 확인 팝업 대기 동작 (confirm 시 무엇을 실행할지). + public enum PendingAction: Equatable { + case logout + case withdraw + } + + @ObservableState + public struct State: Equatable { + public var menuItems: [MenuItem] = MenuItem.allCases + public var pending: PendingAction? + public var isProcessing: Bool = false + @Presents public var customAlert: CustomAlertState? + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case scope(ScopeAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case backTapped + case menuTapped(MenuItem) + } + + public enum AsyncAction: Equatable { + case performLogout + case performWithdraw + } + + public enum InnerAction: Equatable { + case sessionCleared + } + + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) + } + + public enum DelegateAction: Equatable { + case dismiss + case openNotificationSettings + case openPrivacy + case openTerms + /// 로그아웃/탈퇴 완료 → 로그인 화면으로. + case sessionEnded + } + + nonisolated enum CancelID: Hashable { + case auth + } + + @Dependency(\.authUseCase) private var authUseCase + @Dependency(\.keychainManager) private var keychainManager + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .scope(scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } + } +} + +extension SettingsFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backTapped: + return .send(.delegate(.dismiss)) + + case let .menuTapped(item): + switch item { + case .notification: + return .send(.delegate(.openNotificationSettings)) + case .privacy: + return .send(.delegate(.openPrivacy)) + case .terms: + return .send(.delegate(.openTerms)) + case .logout: + state.pending = .logout + state.customAlert = .logout() + return .none + case .withdraw: + state.pending = .withdraw + state.customAlert = .withdraw() + return .none + } + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .performLogout: + guard !state.isProcessing else { return .none } + state.isProcessing = true + return .run { send in + do { + _ = try await authUseCase.logout() + } catch { + Log.error("[SettingsFeature] logout failed: \(error.localizedDescription)") + } + await send(.inner(.sessionCleared)) + } + .cancellable(id: CancelID.auth, cancelInFlight: true) + + case .performWithdraw: + guard !state.isProcessing else { return .none } + state.isProcessing = true + let token = keychainManager.refreshToken() ?? keychainManager.accessToken() ?? "" + return .run { send in + do { + _ = try await authUseCase.withDraw(token: token) + } catch { + Log.error("[SettingsFeature] withdraw failed: \(error.localizedDescription)") + } + await send(.inner(.sessionCleared)) + } + .cancellable(id: CancelID.auth, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case .sessionCleared: + state.isProcessing = false + // 서버 호출 성공/실패와 무관하게 로컬 세션은 정리하고 로그인으로 전환. + keychainManager.clear() + return .send(.delegate(.sessionEnded)) + } + } + + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case let .customAlert(alertAction): + switch alertAction { + case let .presented(customAlertAction): + switch customAlertAction { + case .confirmTapped: + let pending = state.pending + state.pending = nil + state.customAlert = nil + switch pending { + case .logout: + return .send(.async(.performLogout)) + case .withdraw: + return .send(.async(.performWithdraw)) + case .none: + return .none + } + + case .cancelTapped: + state.pending = nil + state.customAlert = nil + return .none + } + + case .dismiss: + state.pending = nil + state.customAlert = nil + return .none + } + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + case .openNotificationSettings: + return .none + case .openPrivacy: + return .none + case .openTerms: + return .none + case .sessionEnded: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Settings/View/SettingsView.swift b/Projects/Presentation/Profile/Sources/Settings/View/SettingsView.swift new file mode 100644 index 00000000..d60fb47d --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Settings/View/SettingsView.swift @@ -0,0 +1,80 @@ +// +// SettingsView.swift +// Profile +// +// 설정 UI — picke.pen `설정`. +// App Bar(백/타이틀) + 메뉴 리스트 + 로그아웃/탈퇴 확인 팝업. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem + +@ViewAction(for: SettingsFeature.self) +public struct SettingsView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + appBar() + menuList() + Spacer(minLength: 0) + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + } +} + +private extension SettingsView { + // MARK: App Bar + + @ViewBuilder + func appBar() -> some View { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "설정") + .foregroundStyle(.gray500) + } + + // MARK: 메뉴 리스트 + + @ViewBuilder + func menuList() -> some View { + VStack(spacing: 0) { + ForEach(store.menuItems) { item in + menuRow(item) + } + } + .padding(.horizontal, 16) + } + + @ViewBuilder + func menuRow(_ item: SettingsFeature.MenuItem) -> some View { + Button { + send(.menuTapped(item)) + } label: { + HStack { + Text(item.rawValue) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(item == .withdraw ? .gray500 : .gray800) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.neutral900) + } + .padding(.vertical, 20) + .contentShape(Rectangle()) + .overlay(alignment: .bottom) { + Rectangle().fill(.beige600).frame(height: 1) + } + } + .buttonStyle(.plain) + } +} diff --git a/Projects/Presentation/Profile/Tests/Sources/rofileTests.swift b/Projects/Presentation/Profile/Tests/Sources/ProfileTests.swift similarity index 100% rename from Projects/Presentation/Profile/Tests/Sources/rofileTests.swift rename to Projects/Presentation/Profile/Tests/Sources/ProfileTests.swift diff --git a/Projects/Presentation/Web/Sources/View/WebView.swift b/Projects/Presentation/Web/Sources/View/WebView.swift index 19b9996a..bcfdf9f1 100644 --- a/Projects/Presentation/Web/Sources/View/WebView.swift +++ b/Projects/Presentation/Web/Sources/View/WebView.swift @@ -20,7 +20,7 @@ public struct WebView: View { public var body: some View { ZStack { - Color.basicBlack + Color.beige50 .edgesIgnoringSafeArea(.all) VStack { @@ -30,7 +30,7 @@ public struct WebView: View { PickeNavigationBar(onBack: { store.send(.backToRoot) }) { Color.clear.frame(width: 24, height: 24) } - .foregroundStyle(.beige50) + .foregroundStyle(.neutral900) Spacer() .frame(height: 20) @@ -40,5 +40,7 @@ public struct WebView: View { } .navigationBarBackButtonHidden(true) } + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) } } diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index 6869683c..158e63ad 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift @@ -50,4 +50,7 @@ public enum ImageAsset: String { case avatarSunja case none + + //MARK: - 프로필 + case history } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift index 1de4fa04..98ed2d3a 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift @@ -38,6 +38,9 @@ public enum CustomAlertStyle: Equatable { case report case alreadyWatched case deleteConfirm + case logout + case withdraw + case suggestTopic } @CasePathable @@ -122,4 +125,37 @@ public extension CustomAlertState where Action == CustomAlertAction { style: .alreadyWatched ) } + + /// 로그아웃 확인 — 확정(로그아웃)=왼쪽 밝은 버튼, 취소(유지)=오른쪽 primary 버튼. + static func logout() -> CustomAlertState { + CustomAlertState( + title: "로그아웃 시 원활한 이용이 어려울 수 있습니다.\n그럼에도 로그아웃하시겠습니까?", + confirmTitle: "네, 로그아웃합니다", + cancelTitle: "그대로 있을게요", + isDestructive: true, + style: .logout + ) + } + + /// 회원 탈퇴 확인 — 확정(탈퇴)=왼쪽 밝은 버튼, 취소(유지)=오른쪽 primary 버튼. + static func withdraw() -> CustomAlertState { + CustomAlertState( + title: "탈퇴 시 지금까지의 이용기록이 영구 삭제 됩니다.\n그럼에도 탈퇴하시겠습니까?", + confirmTitle: "네, 탈퇴합니다", + cancelTitle: "그대로 있을게요", + isDestructive: true, + style: .withdraw + ) + } + + /// 주제 제안 — 제안하기=오른쪽 primary 버튼, 뒤로가기=왼쪽 밝은 버튼. + static func suggestTopic() -> CustomAlertState { + CustomAlertState( + title: "나만의 배틀을 제안해주세요", + message: "-30P를 사용해 원하는 주제를 제안하고,\n채택되면 +100P를 돌려받아요.", + confirmTitle: "제안하기", + cancelTitle: "뒤로가기", + style: .suggestTopic + ) + } } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift index 869fe439..43536f46 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -60,7 +60,7 @@ struct CustomConfirmationPopup: View { switch style { case .confirmation: return 20 - case .finalVote, .report, .alreadyWatched, .deleteConfirm: + case .finalVote, .report, .alreadyWatched, .deleteConfirm, .logout, .withdraw, .suggestTopic: return 0 } } @@ -78,6 +78,106 @@ struct CustomConfirmationPopup: View { alreadyWatchedContent case .deleteConfirm: deleteConfirmContent + case .logout, .withdraw: + logoutWithdrawContent + case .suggestTopic: + suggestTopicContent + } + } + + /// 로그아웃/탈퇴 — 본문(title) + 확정(왼쪽 밝은) / 취소(오른쪽 primary). + private var logoutWithdrawContent: some View { + pickeAlertCard(opacity: 0.9) { + VStack(spacing: 16) { + pickeBodyText(title) + + pickeTwoButtonRow( + leftTitle: confirmTitle, leftAction: onConfirm, + rightTitle: cancelTitle, rightAction: onCancel + ) + } + } + } + + /// 주제 제안 — 타이틀 + 본문 + 뒤로가기(왼쪽 밝은) / 제안하기(오른쪽 primary). + private var suggestTopicContent: some View { + pickeAlertCard { + VStack(spacing: 16) { + VStack(spacing: 10) { + Text(title) + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.primary800) + .multilineTextAlignment(.center) + + if !message.isEmpty { + pickeBodyText(message) + } + } + + pickeTwoButtonRow( + leftTitle: cancelTitle, leftAction: onCancel, + rightTitle: confirmTitle, rightAction: onConfirm + ) + } + } + } + + // MARK: picke 공통 팝업 헬퍼 + + /// 베이지 카드 + primary 보더 컨테이너 (width 313, top padding 20). + private func pickeAlertCard( + opacity: Double = 1, + @ViewBuilder content: () -> some View + ) -> some View { + content() + .padding(.top, 20) + .frame(width: 313) + .background(.beige500, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.primary500, lineWidth: 1.5) + ) + .opacity(opacity) + .clipShape(RoundedRectangle(cornerRadius: 2)) + .onTapGesture {} + } + + /// 본문 텍스트 (14/Medium, primary800, 가운데, 좌우 20). + private func pickeBodyText(_ text: String) -> some View { + Text(text) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.primary800) + .lineSpacing(14 * 0.4) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + } + + /// 좌(밝은)·우(primary) 2버튼 행. 좌/우 의미는 호출부가 결정. + private func pickeTwoButtonRow( + leftTitle: String, leftAction: @escaping () -> Void, + rightTitle: String, rightAction: @escaping () -> Void + ) -> some View { + HStack(spacing: 0) { + Button(action: leftAction) { + Text(leftTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.primary800) + .frame(maxWidth: .infinity) + .padding(.vertical, 17) + .background(.secondary50, in: Rectangle()) + } + .buttonStyle(.plain) + + Button(action: rightAction) { + Text(rightTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.secondary50) + .frame(maxWidth: .infinity) + .padding(.vertical, 17) + .background(.primary500, in: Rectangle()) + } + .buttonStyle(.plain) } } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Navigaion/PickeNavigationBar.swift b/Projects/Shared/DesignSystem/Sources/UI/Navigaion/PickeNavigationBar.swift index 382d4630..c0232191 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Navigaion/PickeNavigationBar.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Navigaion/PickeNavigationBar.swift @@ -16,15 +16,18 @@ import SwiftUI public struct PickeNavigationBar: View { private let onBack: (() -> Void)? private let centerIcon: Image? + private let centerTitle: String? private let trailing: () -> Trailing public init( onBack: (() -> Void)? = nil, centerIcon: Image? = nil, + centerTitle: String? = nil, @ViewBuilder trailing: @escaping () -> Trailing ) { self.onBack = onBack self.centerIcon = centerIcon + self.centerTitle = centerTitle self.trailing = trailing } @@ -56,7 +59,11 @@ public struct PickeNavigationBar: View { @ViewBuilder private var centerArea: some View { - if let centerIcon { + if let centerTitle { + Text(centerTitle) + .pretendardFont(family: .SemiBold, size: 16) + .kerning(-0.4) + } else if let centerIcon { centerIcon .font(.system(size: 16, weight: .semibold)) .frame(width: 24, height: 24) @@ -69,8 +76,9 @@ public struct PickeNavigationBar: View { public extension PickeNavigationBar where Trailing == EmptyView { init( onBack: (() -> Void)? = nil, - centerIcon: Image? = nil + centerIcon: Image? = nil, + centerTitle: String? = nil ) { - self.init(onBack: onBack, centerIcon: centerIcon) { EmptyView() } + self.init(onBack: onBack, centerIcon: centerIcon, centerTitle: centerTitle) { EmptyView() } } } diff --git a/Projects/Shared/Utill/Sources/Extension/Date+.swift b/Projects/Shared/Utill/Sources/Extension/Date+.swift new file mode 100644 index 00000000..497cbd5a --- /dev/null +++ b/Projects/Shared/Utill/Sources/Extension/Date+.swift @@ -0,0 +1,30 @@ +// +// Date+.swift +// Utill +// +// 날짜 포맷 유틸. +// + +import Foundation + +public extension Date { + /// 지정 포맷 문자열로 변환 (ko_KR 고정). + func toString(format: String) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = format + return formatter.string(from: self) + } + + /// `yyyy.M.d` 형식 (예: 2026.4.10). + var yearMonthDayDot: String { + toString(format: "yyyy.M.d") + } +} + +public extension Date? { + /// nil 이면 빈 문자열. + var yearMonthDayDot: String { + self?.yearMonthDayDot ?? "" + } +} From 8d5f4c68e9f974b3e2d5dacd0bf49de5fce41b0e Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 01:19:23 +0900 Subject: [PATCH 06/39] =?UTF-8?q?feat:=20=EB=82=B4=20=EB=B0=B0=ED=8B=80=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20+=20Entity=201=ED=83=80=EC=9E=85=201?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=B6=84=EB=A6=AC=C2=B7=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /me/battle-records 연동 (Clean Architecture 전 레이어, offset 무한 스크롤 + 스켈레톤) - 마이페이지 '내 배틀 기록' 메뉴 → 화면 push - PickeEmptyStateView 공통 컴포넌트(noDataLogo) — 포인트내역/배틀기록 빈 상태 적용 - Entity 멀티-타입 파일 분리(SOLID, 1타입 1파일) + Home 기능별 세부 폴더링 (Battle/Detail·PreVote·VoteStats·Recommend, Comment/Item·Perspective·PerspectiveComment, Scenario/Chat, Section/Battle·Vote) - AGENTS.md: Entity 1타입 1파일 규칙 추가 #9 #12 --- AGENTS.md | 3 +- .../Data/API/Sources/Profile/ProfileAPI.swift | 3 + .../Profile/DTO/BattleRecordDataDTO.swift | 26 ++ .../Profile/Mapper/BattleRecordDataDTO+.swift | 39 +++ .../Profile/ProfileRepositoryImpl.swift | 24 ++ .../Profile/BattleRecordsQueryRequest.swift | 31 ++ .../Sources/Profile/ProfileService.swift | 8 +- .../DefaultProfileRepositoryImpl.swift | 8 + .../Sources/Profile/ProfileInterface.swift | 5 + Projects/Domain/Entity/Sources/Base.swift | 22 -- .../Sources/Home/Battle/BattleDetail.swift | 132 -------- .../Home/Battle/Detail/BattleDetail.swift | 41 +++ .../Home/Battle/Detail/BattleInfo.swift | 42 +++ .../Home/Battle/Detail/BattleOption.swift | 36 +++ .../Home/Battle/Detail/BattleStep.swift | 20 ++ .../Home/Battle/Detail/UserVoteStatus.swift | 17 ++ .../Battle/PreVote/PhilosopherAvatar.swift | 13 + .../Battle/{ => PreVote}/PreVoteBattle.swift | 28 -- .../Home/Battle/PreVote/PreVoteOption.swift | 27 ++ .../Home/Battle/PreVote/PreVoteResult.swift | 19 ++ .../PreVoteResultStatus.swift} | 15 +- .../Battle/Recommend/RecommendedBattle.swift | 42 +++ .../Recommend/RecommendedBattleOption.swift | 30 ++ .../Recommend/RecommendedBattlePage.swift | 22 ++ .../Recommend/RecommendedBattleTag.swift | 18 ++ .../Home/Battle/RecommendedBattle.swift | 94 ------ .../Battle/VoteStats/BattleVoteStats.swift | 25 ++ .../BattleVoteStatsOption.swift} | 21 +- .../Battle/VoteStats/VoteOptionSummary.swift | 31 ++ .../Home/Battle/VoteStats/VoteSummary.swift | 36 +++ .../Sources/Home/Battle/VoteSummary.swift | 103 ------- .../Home/Comment/BattlePerspective.swift | 115 ------- .../Entity/Sources/Home/Comment/Comment.swift | 286 ------------------ .../Sources/Home/Comment/Item/Comment.swift | 36 +++ .../Home/Comment/Item/CommentAuthor.swift | 22 ++ .../Home/Comment/Item/CommentFilter.swift | 29 ++ .../Home/Comment/Item/CommentItem.swift | 89 ++++++ .../{ => Item}/CommentLikeResult.swift | 0 .../Home/Comment/Item/CommentOption.swift | 18 ++ .../Home/Comment/Item/CommentReplyItem.swift | 114 +++++++ .../Home/Comment/Item/CommentSort.swift | 25 ++ .../Home/Comment/Item/CommentSortType.swift | 18 ++ .../Home/Comment/Item/CommentTab.swift | 25 ++ .../Perspective/BattlePerspective.swift | 45 +++ .../Perspective/BattlePerspectiveOption.swift | 27 ++ .../Perspective/BattlePerspectivePage.swift | 22 ++ .../Perspective/BattlePerspectiveSort.swift | 20 ++ .../Perspective/BattlePerspectiveUser.swift | 25 ++ .../Home/Comment/PerspectiveComment.swift | 93 ------ .../PerspectiveComment.swift | 41 +++ .../PerspectiveCommentMutationResult.swift | 23 ++ .../PerspectiveCommentPage.swift | 22 ++ .../PerspectiveCommentUser.swift | 25 ++ .../Entity/Sources/Home/Core/BattleTag.swift | 23 -- .../Entity/Sources/Home/Core/TagType.swift | 29 ++ .../Home/Explore/ExploreCategory.swift | 38 +++ .../Sources/Home/Explore/ExploreItem.swift | 63 ---- .../Home/Explore/ExploreItemPage.swift | 22 ++ .../Sources/Home/Explore/ExploreSort.swift | 21 ++ .../Home/Scenario/BattleScenario.swift | 108 ------- .../Home/Scenario/Chat/ChatMessage.swift | 32 ++ .../ChatRoomBundle.swift} | 55 +--- .../Home/Scenario/Chat/ChatSpeaker.swift | 27 ++ .../Home/Scenario/Chat/ChatSpeakerSide.swift | 12 + .../Home/Scenario/RecommendedPathKey.swift | 15 + .../Scenario/ScenarioInteractiveOption.swift | 21 ++ .../Sources/Home/Scenario/ScenarioNode.swift | 33 ++ .../Home/Scenario/ScenarioPhilosopher.swift | 27 ++ .../Home/Scenario/ScenarioScript.swift | 30 ++ .../Home/Scenario/ScenarioSpeakerType.swift | 18 ++ .../Section/{ => Battle}/BestBattle.swift | 0 .../Section/{ => Battle}/HeroBattle.swift | 0 .../Home/Section/{ => Battle}/HotBattle.swift | 0 .../Home/Section/{ => Battle}/NewBattle.swift | 0 .../Home/Section/Vote/VoteOption.swift | 21 ++ .../Section/{ => Vote}/VoteQuestion.swift | 15 - .../Profile/BattleRecord/BattleRecord.swift | 41 +++ .../BattleRecord/BattleRecordPage.swift | 24 ++ .../Profile/BattleRecord/BattleVoteSide.swift | 21 ++ .../CreditHistoryItem.swift} | 21 +- .../Profile/Credit/CreditHistoryPage.swift | 24 ++ .../Entity/Sources/Profile/MyPage.swift | 95 ------ .../Sources/Profile/MyPage/MyPage.swift | 24 ++ .../Profile/MyPage/MyPhilosopher.swift | 30 ++ .../Sources/Profile/MyPage/MyProfile.swift | 34 +++ .../Sources/Profile/MyPage/MyTier.swift | 24 ++ .../Sources/Profile/ProfileUseCase.swift | 12 + .../Reducer/BattleRecordFeature.swift | 172 +++++++++++ .../View/BattleRecordSkeletonView.swift | 83 +++++ .../BattleRecord/View/BattleRecordView.swift | 120 ++++++++ .../Reducer/ProfileCoordinator.swift | 14 +- .../View/ProfileCoordinatorView.swift | 2 + .../PointHistory/View/PointHistoryView.swift | 12 +- .../UI/Empty/PickeEmptyStateView.swift | 39 +++ 94 files changed, 2204 insertions(+), 1299 deletions(-) create mode 100644 Projects/Data/Model/Sources/Profile/DTO/BattleRecordDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Profile/Mapper/BattleRecordDataDTO+.swift create mode 100644 Projects/Data/Service/Sources/Profile/BattleRecordsQueryRequest.swift delete mode 100644 Projects/Domain/Entity/Sources/Base.swift delete mode 100644 Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleDetail.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleInfo.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleOption.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleStep.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/Detail/UserVoteStatus.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/PreVote/PhilosopherAvatar.swift rename Projects/Domain/Entity/Sources/Home/Battle/{ => PreVote}/PreVoteBattle.swift (73%) create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteOption.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteResult.swift rename Projects/Domain/Entity/Sources/Home/Battle/{PreVoteResult.swift => PreVote/PreVoteResultStatus.swift} (55%) create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattle.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattleOption.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattlePage.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattleTag.swift delete mode 100644 Projects/Domain/Entity/Sources/Home/Battle/RecommendedBattle.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/VoteStats/BattleVoteStats.swift rename Projects/Domain/Entity/Sources/Home/Battle/{BattleVoteStats.swift => VoteStats/BattleVoteStatsOption.swift} (59%) create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/VoteStats/VoteOptionSummary.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Battle/VoteStats/VoteSummary.swift delete mode 100644 Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift delete mode 100644 Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift delete mode 100644 Projects/Domain/Entity/Sources/Home/Comment/Comment.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/Item/Comment.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/Item/CommentAuthor.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/Item/CommentFilter.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/Item/CommentItem.swift rename Projects/Domain/Entity/Sources/Home/Comment/{ => Item}/CommentLikeResult.swift (100%) create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/Item/CommentOption.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/Item/CommentReplyItem.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/Item/CommentSort.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/Item/CommentSortType.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/Item/CommentTab.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspective.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveOption.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectivePage.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveSort.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveUser.swift delete mode 100644 Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveComment.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentMutationResult.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentPage.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentUser.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Core/TagType.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Explore/ExploreCategory.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Explore/ExploreItemPage.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Explore/ExploreSort.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatMessage.swift rename Projects/Domain/Entity/Sources/Home/Scenario/{ChatMessage.swift => Chat/ChatRoomBundle.swift} (65%) create mode 100644 Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatSpeaker.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatSpeakerSide.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Scenario/RecommendedPathKey.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Scenario/ScenarioInteractiveOption.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Scenario/ScenarioNode.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Scenario/ScenarioPhilosopher.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Scenario/ScenarioScript.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Scenario/ScenarioSpeakerType.swift rename Projects/Domain/Entity/Sources/Home/Section/{ => Battle}/BestBattle.swift (100%) rename Projects/Domain/Entity/Sources/Home/Section/{ => Battle}/HeroBattle.swift (100%) rename Projects/Domain/Entity/Sources/Home/Section/{ => Battle}/HotBattle.swift (100%) rename Projects/Domain/Entity/Sources/Home/Section/{ => Battle}/NewBattle.swift (100%) create mode 100644 Projects/Domain/Entity/Sources/Home/Section/Vote/VoteOption.swift rename Projects/Domain/Entity/Sources/Home/Section/{ => Vote}/VoteQuestion.swift (83%) create mode 100644 Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleRecord.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleRecordPage.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleVoteSide.swift rename Projects/Domain/Entity/Sources/Profile/{CreditHistory.swift => Credit/CreditHistoryItem.swift} (71%) create mode 100644 Projects/Domain/Entity/Sources/Profile/Credit/CreditHistoryPage.swift delete mode 100644 Projects/Domain/Entity/Sources/Profile/MyPage.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/MyPage/MyPage.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/MyPage/MyPhilosopher.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/MyPage/MyProfile.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/MyPage/MyTier.swift create mode 100644 Projects/Presentation/Profile/Sources/BattleRecord/Reducer/BattleRecordFeature.swift create mode 100644 Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordSkeletonView.swift create mode 100644 Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordView.swift create mode 100644 Projects/Shared/DesignSystem/Sources/UI/Empty/PickeEmptyStateView.swift diff --git a/AGENTS.md b/AGENTS.md index 4e6224aa..2f39d747 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -443,7 +443,8 @@ public struct VoteSummary: Equatable { ... } // ← Entity 로 ``` 규칙: -- **모든 화면 모델 (struct/enum)** 은 `Projects/Domain/Entity/Sources/<도메인>/` 아래에 둔다 (`Home/Comment.swift`, `Home/Battle.swift` 등) +- **모든 화면 모델 (struct/enum)** 은 `Projects/Domain/Entity/Sources/<도메인>/` 아래에 둔다 (`Home/Battle/...`, `Profile/Credit/...` 등) +- **파일당 public 타입 하나 (SOLID, 필수)** — 한 파일에 `struct`/`enum` 을 여러 개 넣지 않는다. 타입마다 `타입명.swift` 로 분리하고, `extension` 은 해당 타입 파일에 함께 둔다. 기능별 하위 폴더로 폴더링한다 (예: `Profile/MyPage/{MyPage,MyProfile,MyTier}.swift`, `Profile/Credit/{CreditHistoryPage,CreditHistoryItem}.swift`) - Feature 안에는 `State` / `Action` / `Reducer` / `CancelID` 같은 **TCA 컴포넌트만** 둔다 - UI 분기용 enum (`CommentFilter`, `CommentSort`) 도 도메인 모델로 취급해 Entity 에 둔다 — 같은 도메인의 여러 화면에서 재사용 가능 - 서버 응답 매핑 init (`init(item: BattlePerspective, order: Int)`) · 정적 mocks · `.empty` 팩토리도 모두 Entity 쪽에서 정의 diff --git a/Projects/Data/API/Sources/Profile/ProfileAPI.swift b/Projects/Data/API/Sources/Profile/ProfileAPI.swift index 24272958..a151b6ab 100644 --- a/Projects/Data/API/Sources/Profile/ProfileAPI.swift +++ b/Projects/Data/API/Sources/Profile/ProfileAPI.swift @@ -8,6 +8,7 @@ import Foundation public enum ProfileAPI { case mypage case creditsHistory + case battleRecords public var description: String { switch self { @@ -15,6 +16,8 @@ public enum ProfileAPI { return "mypage" case .creditsHistory: return "credits/history" + case .battleRecords: + return "battle-records" } } } diff --git a/Projects/Data/Model/Sources/Profile/DTO/BattleRecordDataDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/BattleRecordDataDTO.swift new file mode 100644 index 00000000..782e2934 --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/DTO/BattleRecordDataDTO.swift @@ -0,0 +1,26 @@ +// +// BattleRecordDataDTO.swift +// Model +// +// `GET /api/v1/me/battle-records` 응답 DTO. +// + +import Foundation + +public struct BattleRecordDataDTO: Decodable { + public let items: [BattleRecordItemDTO]? + public let nextOffset: Int? + public let hasNext: Bool? +} + +public struct BattleRecordItemDTO: Decodable { + public let battleId: String? + public let recordId: String? + public let voteSide: String? + public let category: String? + public let title: String? + public let summary: String? + public let createdAt: String? +} + +public typealias BattleRecordResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Profile/Mapper/BattleRecordDataDTO+.swift b/Projects/Data/Model/Sources/Profile/Mapper/BattleRecordDataDTO+.swift new file mode 100644 index 00000000..d5d917e1 --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/Mapper/BattleRecordDataDTO+.swift @@ -0,0 +1,39 @@ +// +// BattleRecordDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension BattleRecordDataDTO { + func toDomain() -> BattleRecordPage { + BattleRecordPage( + items: (items ?? []).map { $0.toDomain() }, + nextOffset: nextOffset ?? 0, + hasNext: hasNext ?? false + ) + } +} + +public extension BattleRecordItemDTO { + func toDomain() -> BattleRecord { + BattleRecord( + battleId: battleId ?? "", + recordId: recordId ?? "", + voteSide: BattleVoteSide(rawValue: voteSide ?? ""), + category: category ?? "", + title: title ?? "", + summary: summary ?? "", + createdAt: createdAt.flatMap(Self.parseISO8601) + ) + } + + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} diff --git a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift index 1479d4c9..816bef56 100644 --- a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift @@ -52,4 +52,28 @@ public final class ProfileRepositoryImpl: ProfileInterface, @unchecked Sendable return data.toDomain() } + + public func fetchBattleRecords( + offset: Int, + size: Int, + voteSide: BattleVoteSide? + ) async throws -> BattleRecordPage { + let dto: BattleRecordResponseDTO = try await provider.request( + .battleRecords( + query: BattleRecordsQueryRequest( + offset: offset, + size: size, + voteSide: voteSide.flatMap { $0 == .unknown ? nil : $0.rawValue } + ) + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "배틀 기록 응답이 비어 있습니다" + Log.error("[ProfileRepositoryImpl] empty battleRecords payload: \(message)") + throw ProfileError.backendError(message) + } + + return data.toDomain() + } } diff --git a/Projects/Data/Service/Sources/Profile/BattleRecordsQueryRequest.swift b/Projects/Data/Service/Sources/Profile/BattleRecordsQueryRequest.swift new file mode 100644 index 00000000..31491004 --- /dev/null +++ b/Projects/Data/Service/Sources/Profile/BattleRecordsQueryRequest.swift @@ -0,0 +1,31 @@ +// +// BattleRecordsQueryRequest.swift +// Service +// +// GET /api/v1/me/battle-records 쿼리 파라미터. +// + +import Foundation + +public struct BattleRecordsQueryRequest: Encodable { + public let offset: Int? + public let size: Int? + /// 투표 진영 필터 (PRO / CON). nil 이면 전체. + public let voteSide: String? + + enum CodingKeys: String, CodingKey { + case offset + case size + case voteSide = "vote_side" + } + + public init( + offset: Int? = nil, + size: Int? = nil, + voteSide: String? = nil + ) { + self.offset = offset + self.size = size + self.voteSide = voteSide + } +} diff --git a/Projects/Data/Service/Sources/Profile/ProfileService.swift b/Projects/Data/Service/Sources/Profile/ProfileService.swift index aaa203c7..7e55390f 100644 --- a/Projects/Data/Service/Sources/Profile/ProfileService.swift +++ b/Projects/Data/Service/Sources/Profile/ProfileService.swift @@ -13,6 +13,7 @@ import AsyncMoya public enum ProfileService { case mypage case creditsHistory(query: CreditHistoryQueryRequest) + case battleRecords(query: BattleRecordsQueryRequest) } extension ProfileService: BaseTargetType { @@ -26,6 +27,8 @@ extension ProfileService: BaseTargetType { return ProfileAPI.mypage.description case .creditsHistory: return ProfileAPI.creditsHistory.description + case .battleRecords: + return ProfileAPI.battleRecords.description } } @@ -33,7 +36,7 @@ extension ProfileService: BaseTargetType { public var method: Moya.Method { switch self { - case .mypage, .creditsHistory: + case .mypage, .creditsHistory, .battleRecords: return .get } } @@ -45,6 +48,9 @@ extension ProfileService: BaseTargetType { case let .creditsHistory(query): guard let dict = query.toDictionary else { return nil } return dict.isEmpty ? nil : dict + case let .battleRecords(query): + guard let dict = query.toDictionary else { return nil } + return dict.isEmpty ? nil : dict } } diff --git a/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift index 61e36fe4..e6a18082 100644 --- a/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift @@ -40,4 +40,12 @@ public struct DefaultProfileRepositoryImpl: ProfileInterface { ) async throws -> CreditHistoryPage { CreditHistoryPage(items: [], nextOffset: 0, hasNext: false) } + + public func fetchBattleRecords( + offset _: Int, + size _: Int, + voteSide _: BattleVoteSide? + ) async throws -> BattleRecordPage { + BattleRecordPage(items: [], nextOffset: 0, hasNext: false) + } } diff --git a/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift index dbf62bd9..3cf2860d 100644 --- a/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift @@ -13,6 +13,11 @@ public protocol ProfileInterface: Sendable { offset: Int, size: Int ) async throws -> CreditHistoryPage + func fetchBattleRecords( + offset: Int, + size: Int, + voteSide: BattleVoteSide? + ) async throws -> BattleRecordPage } public struct ProfileRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/Entity/Sources/Base.swift b/Projects/Domain/Entity/Sources/Base.swift deleted file mode 100644 index fc5212e4..00000000 --- a/Projects/Domain/Entity/Sources/Base.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// base.swift -// DDDAttendance. -// -// Created by Roy on 2025-10-22 -// Copyright © 2025 DDD , Ltd., All rights reserved. -// - -import SwiftUI - -struct BaseView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("Hello, world!") - } - .padding() - } -} - diff --git a/Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift b/Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift deleted file mode 100644 index bd45191f..00000000 --- a/Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// BattleDetail.swift -// Entity -// -// `GET /api/v1/battles/{battleId}` 응답 도메인 모델. -// - -import Foundation - -public struct BattleDetail: Equatable, Identifiable { - public let battleInfo: BattleInfo - public let description: String - public let shareUrl: String - public let userVoteStatus: UserVoteStatus - public let currentStep: BattleStep - public let categoryTags: [BattleTag] - public let philosopherTags: [BattleTag] - public let valueTags: [BattleTag] - - public var id: Int { battleInfo.battleId } - - public init( - battleInfo: BattleInfo, - description: String, - shareUrl: String, - userVoteStatus: UserVoteStatus, - currentStep: BattleStep, - categoryTags: [BattleTag], - philosopherTags: [BattleTag], - valueTags: [BattleTag] - ) { - self.battleInfo = battleInfo - self.description = description - self.shareUrl = shareUrl - self.userVoteStatus = userVoteStatus - self.currentStep = currentStep - self.categoryTags = categoryTags - self.philosopherTags = philosopherTags - self.valueTags = valueTags - } -} - -public struct BattleInfo: Equatable, Identifiable { - public let battleId: Int - public let title: String - public let summary: String - public let thumbnailUrl: String - public let viewCount: Int - public let participantsCount: Int - public let audioDuration: Int - public let tags: [BattleTag] - public let options: [BattleOption] - - public var id: Int { battleId } - - public init( - battleId: Int, - title: String, - summary: String, - thumbnailUrl: String, - viewCount: Int, - participantsCount: Int, - audioDuration: Int, - tags: [BattleTag], - options: [BattleOption] - ) { - self.battleId = battleId - self.title = title - self.summary = summary - self.thumbnailUrl = thumbnailUrl - self.viewCount = viewCount - self.participantsCount = participantsCount - self.audioDuration = audioDuration - self.tags = tags - self.options = options - } -} - -public struct BattleOption: Equatable, Identifiable, Hashable { - public let optionId: Int - public let label: String - public let title: String - public let stance: String - public let representative: String - public let imageUrl: String - public let tags: [BattleTag] - - public var id: Int { optionId } - - public init( - optionId: Int, - label: String, - title: String, - stance: String, - representative: String, - imageUrl: String, - tags: [BattleTag] - ) { - self.optionId = optionId - self.label = label - self.title = title - self.stance = stance - self.representative = representative - self.imageUrl = imageUrl - self.tags = tags - } -} - -public enum UserVoteStatus: String, Equatable, Hashable, CaseIterable { - case none = "NONE" - case pro = "PRO" - case con = "CON" - case unknown - - public init(rawValue: String) { - self = UserVoteStatus.allCases.first { $0.rawValue == rawValue } ?? .unknown - } -} - -public enum BattleStep: String, Equatable, Hashable, CaseIterable { - case none = "NONE" - case preVote = "PRE_VOTE" - case listening = "LISTENING" - case postVote = "POST_VOTE" - case finished = "FINISHED" - case completed = "COMPLETED" - case unknown - - public init(rawValue: String) { - self = BattleStep.allCases.first { $0.rawValue == rawValue } ?? .unknown - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleDetail.swift b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleDetail.swift new file mode 100644 index 00000000..c2af1660 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleDetail.swift @@ -0,0 +1,41 @@ +// +// BattleDetail.swift +// Entity +// +// `GET /api/v1/battles/{battleId}` 응답 도메인 모델. +// + +import Foundation + +public struct BattleDetail: Equatable, Identifiable { + public let battleInfo: BattleInfo + public let description: String + public let shareUrl: String + public let userVoteStatus: UserVoteStatus + public let currentStep: BattleStep + public let categoryTags: [BattleTag] + public let philosopherTags: [BattleTag] + public let valueTags: [BattleTag] + + public var id: Int { battleInfo.battleId } + + public init( + battleInfo: BattleInfo, + description: String, + shareUrl: String, + userVoteStatus: UserVoteStatus, + currentStep: BattleStep, + categoryTags: [BattleTag], + philosopherTags: [BattleTag], + valueTags: [BattleTag] + ) { + self.battleInfo = battleInfo + self.description = description + self.shareUrl = shareUrl + self.userVoteStatus = userVoteStatus + self.currentStep = currentStep + self.categoryTags = categoryTags + self.philosopherTags = philosopherTags + self.valueTags = valueTags + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleInfo.swift b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleInfo.swift new file mode 100644 index 00000000..eb6fa33c --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleInfo.swift @@ -0,0 +1,42 @@ +// +// BattleInfo.swift +// Entity +// + +import Foundation + +public struct BattleInfo: Equatable, Identifiable { + public let battleId: Int + public let title: String + public let summary: String + public let thumbnailUrl: String + public let viewCount: Int + public let participantsCount: Int + public let audioDuration: Int + public let tags: [BattleTag] + public let options: [BattleOption] + + public var id: Int { battleId } + + public init( + battleId: Int, + title: String, + summary: String, + thumbnailUrl: String, + viewCount: Int, + participantsCount: Int, + audioDuration: Int, + tags: [BattleTag], + options: [BattleOption] + ) { + self.battleId = battleId + self.title = title + self.summary = summary + self.thumbnailUrl = thumbnailUrl + self.viewCount = viewCount + self.participantsCount = participantsCount + self.audioDuration = audioDuration + self.tags = tags + self.options = options + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleOption.swift b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleOption.swift new file mode 100644 index 00000000..6f539817 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleOption.swift @@ -0,0 +1,36 @@ +// +// BattleOption.swift +// Entity +// + +import Foundation + +public struct BattleOption: Equatable, Identifiable, Hashable { + public let optionId: Int + public let label: String + public let title: String + public let stance: String + public let representative: String + public let imageUrl: String + public let tags: [BattleTag] + + public var id: Int { optionId } + + public init( + optionId: Int, + label: String, + title: String, + stance: String, + representative: String, + imageUrl: String, + tags: [BattleTag] + ) { + self.optionId = optionId + self.label = label + self.title = title + self.stance = stance + self.representative = representative + self.imageUrl = imageUrl + self.tags = tags + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleStep.swift b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleStep.swift new file mode 100644 index 00000000..233baf52 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleStep.swift @@ -0,0 +1,20 @@ +// +// BattleStep.swift +// Entity +// + +import Foundation + +public enum BattleStep: String, Equatable, Hashable, CaseIterable { + case none = "NONE" + case preVote = "PRE_VOTE" + case listening = "LISTENING" + case postVote = "POST_VOTE" + case finished = "FINISHED" + case completed = "COMPLETED" + case unknown + + public init(rawValue: String) { + self = BattleStep.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Detail/UserVoteStatus.swift b/Projects/Domain/Entity/Sources/Home/Battle/Detail/UserVoteStatus.swift new file mode 100644 index 00000000..056db1cf --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Detail/UserVoteStatus.swift @@ -0,0 +1,17 @@ +// +// UserVoteStatus.swift +// Entity +// + +import Foundation + +public enum UserVoteStatus: String, Equatable, Hashable, CaseIterable { + case none = "NONE" + case pro = "PRO" + case con = "CON" + case unknown + + public init(rawValue: String) { + self = UserVoteStatus.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PhilosopherAvatar.swift b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PhilosopherAvatar.swift new file mode 100644 index 00000000..cece14bf --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PhilosopherAvatar.swift @@ -0,0 +1,13 @@ +// +// PhilosopherAvatar.swift +// Entity +// + +import Foundation + +/// 사전 투표창 / 새로운 배틀 카드에서 사용되는 철학자 아바타. raw value 는 화면 표시 이름. +public enum PhilosopherAvatar: String, CaseIterable, Equatable, Hashable { + case plato = "플라톤" + case sartre = "사르트르" + case sunja = "순자" +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/PreVoteBattle.swift b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteBattle.swift similarity index 73% rename from Projects/Domain/Entity/Sources/Home/Battle/PreVoteBattle.swift rename to Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteBattle.swift index c60b0406..429d1fcf 100644 --- a/Projects/Domain/Entity/Sources/Home/Battle/PreVoteBattle.swift +++ b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteBattle.swift @@ -42,34 +42,6 @@ public struct PreVoteBattle: Equatable, Identifiable { } } -public struct PreVoteOption: Equatable, Identifiable, Hashable { - public let optionId: Int - public let representative: String - public let imageURL: String - public let stance: String - - public var id: Int { optionId } - - public init( - optionId: Int, - representative: String, - imageURL: String, - stance: String - ) { - self.optionId = optionId - self.representative = representative - self.imageURL = imageURL - self.stance = stance - } -} - -/// 사전 투표창 / 새로운 배틀 카드에서 사용되는 철학자 아바타. raw value 는 화면 표시 이름. -public enum PhilosopherAvatar: String, CaseIterable, Equatable, Hashable { - case plato = "플라톤" - case sartre = "사르트르" - case sunja = "순자" -} - public extension PreVoteBattle { static let mock = PreVoteBattle( battleId: 41, diff --git a/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteOption.swift b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteOption.swift new file mode 100644 index 00000000..186de3e5 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteOption.swift @@ -0,0 +1,27 @@ +// +// PreVoteOption.swift +// Entity +// + +import Foundation + +public struct PreVoteOption: Equatable, Identifiable, Hashable { + public let optionId: Int + public let representative: String + public let imageURL: String + public let stance: String + + public var id: Int { optionId } + + public init( + optionId: Int, + representative: String, + imageURL: String, + stance: String + ) { + self.optionId = optionId + self.representative = representative + self.imageURL = imageURL + self.stance = stance + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteResult.swift b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteResult.swift new file mode 100644 index 00000000..88edf846 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteResult.swift @@ -0,0 +1,19 @@ +// +// PreVoteResult.swift +// Entity +// + +import Foundation + +public struct PreVoteResult: Equatable, Hashable { + public let voteId: Int + public let status: PreVoteResultStatus + + public init( + voteId: Int, + status: PreVoteResultStatus + ) { + self.voteId = voteId + self.status = status + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/PreVoteResult.swift b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteResultStatus.swift similarity index 55% rename from Projects/Domain/Entity/Sources/Home/Battle/PreVoteResult.swift rename to Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteResultStatus.swift index 5744485f..e95839f8 100644 --- a/Projects/Domain/Entity/Sources/Home/Battle/PreVoteResult.swift +++ b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteResultStatus.swift @@ -1,23 +1,10 @@ // -// PreVoteResult.swift +// PreVoteResultStatus.swift // Entity // import Foundation -public struct PreVoteResult: Equatable, Hashable { - public let voteId: Int - public let status: PreVoteResultStatus - - public init( - voteId: Int, - status: PreVoteResultStatus - ) { - self.voteId = voteId - self.status = status - } -} - public enum PreVoteResultStatus: String, Equatable, Hashable, CaseIterable { case none = "NONE" case created = "CREATED" diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattle.swift b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattle.swift new file mode 100644 index 00000000..131a6eea --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattle.swift @@ -0,0 +1,42 @@ +// +// RecommendedBattle.swift +// Entity +// +// `GET /api/v1/battles/{battleId}/recommendations/interesting` 응답 도메인 모델. +// 특정 배틀 기준 흥미로운 배틀 추천 목록 + 커서 페이지네이션. +// + +import Foundation + +public struct RecommendedBattle: Equatable, Identifiable, Hashable { + public let battleId: Int + public let title: String + public let summary: String + public let audioDuration: Int + public let viewCount: Int + public let tags: [RecommendedBattleTag] + public let participantsCount: Int + public let options: [RecommendedBattleOption] + + public var id: Int { battleId } + + public init( + battleId: Int, + title: String, + summary: String, + audioDuration: Int, + viewCount: Int, + tags: [RecommendedBattleTag], + participantsCount: Int, + options: [RecommendedBattleOption] + ) { + self.battleId = battleId + self.title = title + self.summary = summary + self.audioDuration = audioDuration + self.viewCount = viewCount + self.tags = tags + self.participantsCount = participantsCount + self.options = options + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattleOption.swift b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattleOption.swift new file mode 100644 index 00000000..3fc8df2b --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattleOption.swift @@ -0,0 +1,30 @@ +// +// RecommendedBattleOption.swift +// Entity +// + +import Foundation + +public struct RecommendedBattleOption: Equatable, Hashable, Identifiable { + public let optionId: Int + public let title: String + public let stance: String + public let representative: String + public let imageUrl: String? + + public var id: Int { optionId } + + public init( + optionId: Int, + title: String, + stance: String, + representative: String, + imageUrl: String? + ) { + self.optionId = optionId + self.title = title + self.stance = stance + self.representative = representative + self.imageUrl = imageUrl + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattlePage.swift b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattlePage.swift new file mode 100644 index 00000000..cea9e6b6 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattlePage.swift @@ -0,0 +1,22 @@ +// +// RecommendedBattlePage.swift +// Entity +// + +import Foundation + +public struct RecommendedBattlePage: Equatable { + public let items: [RecommendedBattle] + public let nextCursor: String? + public let hasNext: Bool + + public init( + items: [RecommendedBattle], + nextCursor: String?, + hasNext: Bool + ) { + self.items = items + self.nextCursor = nextCursor + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattleTag.swift b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattleTag.swift new file mode 100644 index 00000000..f766cc2d --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattleTag.swift @@ -0,0 +1,18 @@ +// +// RecommendedBattleTag.swift +// Entity +// + +import Foundation + +public struct RecommendedBattleTag: Equatable, Hashable, Identifiable { + public let tagId: Int + public let name: String + + public var id: Int { tagId } + + public init(tagId: Int, name: String) { + self.tagId = tagId + self.name = name + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/RecommendedBattle.swift b/Projects/Domain/Entity/Sources/Home/Battle/RecommendedBattle.swift deleted file mode 100644 index bbd9c55c..00000000 --- a/Projects/Domain/Entity/Sources/Home/Battle/RecommendedBattle.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// RecommendedBattle.swift -// Entity -// -// `GET /api/v1/battles/{battleId}/recommendations/interesting` 응답 도메인 모델. -// 특정 배틀 기준 흥미로운 배틀 추천 목록 + 커서 페이지네이션. -// - -import Foundation - -public struct RecommendedBattlePage: Equatable { - public let items: [RecommendedBattle] - public let nextCursor: String? - public let hasNext: Bool - - public init( - items: [RecommendedBattle], - nextCursor: String?, - hasNext: Bool - ) { - self.items = items - self.nextCursor = nextCursor - self.hasNext = hasNext - } -} - -public struct RecommendedBattle: Equatable, Identifiable, Hashable { - public let battleId: Int - public let title: String - public let summary: String - public let audioDuration: Int - public let viewCount: Int - public let tags: [RecommendedBattleTag] - public let participantsCount: Int - public let options: [RecommendedBattleOption] - - public var id: Int { battleId } - - public init( - battleId: Int, - title: String, - summary: String, - audioDuration: Int, - viewCount: Int, - tags: [RecommendedBattleTag], - participantsCount: Int, - options: [RecommendedBattleOption] - ) { - self.battleId = battleId - self.title = title - self.summary = summary - self.audioDuration = audioDuration - self.viewCount = viewCount - self.tags = tags - self.participantsCount = participantsCount - self.options = options - } -} - -public struct RecommendedBattleTag: Equatable, Hashable, Identifiable { - public let tagId: Int - public let name: String - - public var id: Int { tagId } - - public init(tagId: Int, name: String) { - self.tagId = tagId - self.name = name - } -} - -public struct RecommendedBattleOption: Equatable, Hashable, Identifiable { - public let optionId: Int - public let title: String - public let stance: String - public let representative: String - public let imageUrl: String? - - public var id: Int { optionId } - - public init( - optionId: Int, - title: String, - stance: String, - representative: String, - imageUrl: String? - ) { - self.optionId = optionId - self.title = title - self.stance = stance - self.representative = representative - self.imageUrl = imageUrl - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/BattleVoteStats.swift b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/BattleVoteStats.swift new file mode 100644 index 00000000..e05defaa --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/BattleVoteStats.swift @@ -0,0 +1,25 @@ +// +// BattleVoteStats.swift +// Entity +// +// `GET /api/v1/battles/{battleId}/vote-stats` 응답 도메인 모델. +// 댓글 화면 상단의 옵션별 비율 막대 / 참여자 수 표시에 사용. +// + +import Foundation + +public struct BattleVoteStats: Equatable { + public let options: [BattleVoteStatsOption] + public let totalCount: Int + public let updatedAt: Date? + + public init( + options: [BattleVoteStatsOption], + totalCount: Int, + updatedAt: Date? + ) { + self.options = options + self.totalCount = totalCount + self.updatedAt = updatedAt + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/BattleVoteStats.swift b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/BattleVoteStatsOption.swift similarity index 59% rename from Projects/Domain/Entity/Sources/Home/Battle/BattleVoteStats.swift rename to Projects/Domain/Entity/Sources/Home/Battle/VoteStats/BattleVoteStatsOption.swift index 71d5fe9b..4be80567 100644 --- a/Projects/Domain/Entity/Sources/Home/Battle/BattleVoteStats.swift +++ b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/BattleVoteStatsOption.swift @@ -1,29 +1,10 @@ // -// BattleVoteStats.swift +// BattleVoteStatsOption.swift // Entity // -// `GET /api/v1/battles/{battleId}/vote-stats` 응답 도메인 모델. -// 댓글 화면 상단의 옵션별 비율 막대 / 참여자 수 표시에 사용. -// import Foundation -public struct BattleVoteStats: Equatable { - public let options: [BattleVoteStatsOption] - public let totalCount: Int - public let updatedAt: Date? - - public init( - options: [BattleVoteStatsOption], - totalCount: Int, - updatedAt: Date? - ) { - self.options = options - self.totalCount = totalCount - self.updatedAt = updatedAt - } -} - public struct BattleVoteStatsOption: Equatable, Identifiable, Hashable { public let optionId: Int public let label: String? diff --git a/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/VoteOptionSummary.swift b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/VoteOptionSummary.swift new file mode 100644 index 00000000..6138adc5 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/VoteOptionSummary.swift @@ -0,0 +1,31 @@ +// +// VoteOptionSummary.swift +// Entity +// + +import Foundation + +public struct VoteOptionSummary: Equatable { + public var optionId: Int + public var label: String + public var title: String + public var representative: String + public var imageUrl: String? + public var percentage: Double + + public init( + optionId: Int = 0, + label: String, + title: String, + representative: String, + imageUrl: String? = nil, + percentage: Double + ) { + self.optionId = optionId + self.label = label + self.title = title + self.representative = representative + self.imageUrl = imageUrl + self.percentage = percentage + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/VoteSummary.swift b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/VoteSummary.swift new file mode 100644 index 00000000..bad26b4e --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/VoteSummary.swift @@ -0,0 +1,36 @@ +// +// VoteSummary.swift +// Entity +// +// 댓글 화면 상단 투표 통계 모델. +// + +import Foundation + +public struct VoteSummary: Equatable { + public var changeBadgeTitle: String + public var optionA: VoteOptionSummary + public var optionB: VoteOptionSummary + + public init( + changeBadgeTitle: String, + optionA: VoteOptionSummary, + optionB: VoteOptionSummary + ) { + self.changeBadgeTitle = changeBadgeTitle + self.optionA = optionA + self.optionB = optionB + } + + public static let mock = VoteSummary( + changeBadgeTitle: "생각이 바뀌었어요", + optionA: .init(label: "A", title: "변기는 변기다", representative: "플라톤", percentage: 0.595), + optionB: .init(label: "B", title: "예술이다", representative: "사르트르", percentage: 0.405) + ) + + public static let empty = VoteSummary( + changeBadgeTitle: "생각이 바뀌었어요", + optionA: .init(label: "A", title: "", representative: "", percentage: 0), + optionB: .init(label: "B", title: "", representative: "", percentage: 0) + ) +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift b/Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift deleted file mode 100644 index 187e1360..00000000 --- a/Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// VoteSummary.swift -// Entity -// -// 댓글 화면 상단 투표 통계 모델. -// - -import Foundation - -public struct VoteSummary: Equatable { - public var changeBadgeTitle: String - public var optionA: VoteOptionSummary - public var optionB: VoteOptionSummary - - public init( - changeBadgeTitle: String, - optionA: VoteOptionSummary, - optionB: VoteOptionSummary - ) { - self.changeBadgeTitle = changeBadgeTitle - self.optionA = optionA - self.optionB = optionB - } - - public static let mock = VoteSummary( - changeBadgeTitle: "생각이 바뀌었어요", - optionA: .init(label: "A", title: "변기는 변기다", representative: "플라톤", percentage: 0.595), - optionB: .init(label: "B", title: "예술이다", representative: "사르트르", percentage: 0.405) - ) - - public static let empty = VoteSummary( - changeBadgeTitle: "생각이 바뀌었어요", - optionA: .init(label: "A", title: "", representative: "", percentage: 0), - optionB: .init(label: "B", title: "", representative: "", percentage: 0) - ) -} - -public struct VoteOptionSummary: Equatable { - public var optionId: Int - public var label: String - public var title: String - public var representative: String - public var imageUrl: String? - public var percentage: Double - - public init( - optionId: Int = 0, - label: String, - title: String, - representative: String, - imageUrl: String? = nil, - percentage: Double - ) { - self.optionId = optionId - self.label = label - self.title = title - self.representative = representative - self.imageUrl = imageUrl - self.percentage = percentage - } -} - -public enum CommentFilter: String, CaseIterable, Equatable { - case all - case optionA - case optionB - - public var title: String { - switch self { - case .all: "전체" - case .optionA: "A" - case .optionB: "B" - } - } - - /// 서버 쿼리에 보낼 optionLabel — `all` 은 nil. - public var queryLabel: String? { - switch self { - case .all: nil - case .optionA: "A" - case .optionB: "B" - } - } -} - -public enum CommentSort: String, CaseIterable, Equatable { - case popular - case latest - - public var title: String { - switch self { - case .popular: "인기순" - case .latest: "최신순" - } - } - - public var perspectiveSort: BattlePerspectiveSort { - switch self { - case .popular: .popular - case .latest: .latest - } - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift b/Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift deleted file mode 100644 index c3fd6a1e..00000000 --- a/Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// BattlePerspective.swift -// Entity -// -// `GET /api/v1/battles/{battleId}/perspectives` 응답 도메인 모델. -// 댓글(=관점) 리스트 + 커서 페이지네이션. -// - -import Foundation - -public struct BattlePerspectivePage: Equatable { - public let items: [BattlePerspective] - public let nextCursor: String? - public let hasNext: Bool - - public init( - items: [BattlePerspective], - nextCursor: String?, - hasNext: Bool - ) { - self.items = items - self.nextCursor = nextCursor - self.hasNext = hasNext - } -} - -public struct BattlePerspective: Equatable, Identifiable, Hashable { - public let perspectiveId: Int - public let user: BattlePerspectiveUser - public let option: BattlePerspectiveOption - public let content: String - public let likeCount: Int - public let commentCount: Int - public let isLiked: Bool - public let isMyPerspective: Bool - public let createdAt: Date? - - public var id: Int { perspectiveId } - - public init( - perspectiveId: Int, - user: BattlePerspectiveUser, - option: BattlePerspectiveOption, - content: String, - likeCount: Int, - commentCount: Int, - isLiked: Bool, - isMyPerspective: Bool, - createdAt: Date? - ) { - self.perspectiveId = perspectiveId - self.user = user - self.option = option - self.content = content - self.likeCount = likeCount - self.commentCount = commentCount - self.isLiked = isLiked - self.isMyPerspective = isMyPerspective - self.createdAt = createdAt - } -} - -public struct BattlePerspectiveUser: Equatable, Hashable { - public let userTag: String - public let nickname: String - public let characterType: String - public let characterImageUrl: String? - - public init( - userTag: String, - nickname: String, - characterType: String, - characterImageUrl: String? - ) { - self.userTag = userTag - self.nickname = nickname - self.characterType = characterType - self.characterImageUrl = characterImageUrl - } -} - -public struct BattlePerspectiveOption: Equatable, Hashable, Identifiable { - public let optionId: Int - public let label: String? - public let title: String - public let stance: String - - public var id: Int { optionId } - - public init( - optionId: Int, - label: String?, - title: String, - stance: String - ) { - self.optionId = optionId - self.label = label - self.title = title - self.stance = stance - } -} - -public enum BattlePerspectiveSort: String, Equatable, Hashable, CaseIterable { - case popular - case latest - - public var queryValue: String { rawValue } - - public var title: String { - switch self { - case .popular: "인기순" - case .latest: "최신순" - } - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift b/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift deleted file mode 100644 index f5e51924..00000000 --- a/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift +++ /dev/null @@ -1,286 +0,0 @@ -// -// Comment.swift -// Entity -// -// picke.pen `댓글화면` (j3GDzL) 매핑 도메인 모델. -// - -import Foundation - -public enum CommentOption: Equatable { - case a - case b - - public var label: String { - switch self { - case .a: "A" - case .b: "B" - } - } -} - -public struct CommentAuthor: Equatable, Hashable { - public let name: String - public let imageURL: String? - public let optionLabel: String? - - public init( - name: String, - imageURL: String? = nil, - optionLabel: String? = nil - ) { - self.name = name - self.imageURL = imageURL - self.optionLabel = optionLabel - } -} - -public struct Comment: Equatable, Identifiable, Hashable { - public let id: Int - public let author: CommentAuthor - public let text: String - public let createdAt: Date - public let likeCount: Int - public let replyCount: Int - public let isLiked: Bool - - public init( - id: Int, - author: CommentAuthor, - text: String, - createdAt: Date, - likeCount: Int, - replyCount: Int, - isLiked: Bool - ) { - self.id = id - self.author = author - self.text = text - self.createdAt = createdAt - self.likeCount = likeCount - self.replyCount = replyCount - self.isLiked = isLiked - } -} - -public struct CommentReplyItem: Equatable, Identifiable { - public let id: UUID - public var commentId: Int? - public var author: String - public var authorImageURL: String? - public var timeAgo: String - public var option: CommentOption - public var content: String - public var likeCount: Int - public var isLiked: Bool - public var isMine: Bool - public var createdOrder: Int - - public init( - id: UUID = UUID(), - commentId: Int? = nil, - author: String, - authorImageURL: String? = nil, - timeAgo: String, - option: CommentOption, - content: String, - likeCount: Int, - isLiked: Bool = false, - isMine: Bool = false, - createdOrder: Int - ) { - self.id = id - self.commentId = commentId - self.author = author - self.authorImageURL = authorImageURL - self.timeAgo = timeAgo - self.option = option - self.content = content - self.likeCount = likeCount - self.isLiked = isLiked - self.isMine = isMine - self.createdOrder = createdOrder - } - - /// 서버 PerspectiveComment 응답을 화면 모델로 변환. - public init( - item: PerspectiveComment, - parentOption: CommentOption, - order: Int - ) { - let id = UUID(uuidString: Self.deterministicUUID(commentId: item.commentId)) ?? UUID() - self.init( - id: id, - commentId: item.commentId, - author: item.user.nickname, - authorImageURL: item.user.characterImageUrl, - timeAgo: Self.relativeTimeString(from: item.createdAt), - option: parentOption, - content: item.content, - likeCount: item.likeCount, - isLiked: item.isLiked, - isMine: item.isMine, - createdOrder: order - ) - } - - private static func deterministicUUID(commentId: Int) -> String { - let hex = String(format: "%012X", commentId) - return "00000000-0000-0000-0002-\(hex)" - } - - private static func relativeTimeString(from date: Date?) -> String { - guard let date else { return "방금 전" } - let interval = Date().timeIntervalSince(date) - if interval < 60 { return "방금 전" } - if interval < 3600 { return "\(Int(interval / 60))분 전" } - if interval < 86400 { return "\(Int(interval / 3600))시간 전" } - return "\(Int(interval / 86400))일 전" - } - - public static func mocks(for option: CommentOption) -> [CommentReplyItem] { - [ - .init( - id: UUID(uuidString: "00000000-0000-0000-0001-000000000001") ?? UUID(), - author: "사색하는 사슴", - timeAgo: "2분 전", - option: option, - content: "네덜란드 사례를 일반화하기엔 무리가 있지 않나요? 한국의 사회문화적 맥락은 다릅니다.", - likeCount: 1340, - createdOrder: 3 - ), - .init( - id: UUID(uuidString: "00000000-0000-0000-0001-000000000002") ?? UUID(), - author: "논쟁하는 사자", - timeAgo: "2분 전", - option: option, - content: "제도 자체보다 사각지대를 줄이는 보완책을 같이 봐야 한다고 생각해요.", - likeCount: 534, - createdOrder: 2 - ), - .init( - id: UUID(uuidString: "00000000-0000-0000-0001-000000000003") ?? UUID(), - author: "질문하는 독자", - timeAgo: "5분 전", - option: option == .a ? .b : .a, - content: "반대 입장도 이해되지만, 개인의 자기결정권을 완전히 배제하기는 어렵지 않을까요?", - likeCount: 219, - createdOrder: 1 - ), - ] - } -} - -public enum CommentSortType: String, Equatable, Hashable, CaseIterable { - case popular = "POPULAR" - case latest = "LATEST" - - public var title: String { - switch self { - case .popular: "인기순" - case .latest: "최신순" - } - } -} - -public enum CommentTab: Equatable, Hashable { - case all - case option(label: String, title: String) - - public var title: String { - switch self { - case .all: "전체" - case let .option(_, title): title - } - } - - public var optionLabel: String? { - switch self { - case .all: nil - case let .option(label, _): label - } - } -} - -public struct CommentItem: Equatable, Identifiable { - public let id: UUID - public var perspectiveId: Int? - public var author: String - public var authorImageURL: String? - public var timeAgo: String - public var option: CommentOption - public var optionLabel: String? - public var content: String - public var replyCount: Int - public var likeCount: Int - public var isLiked: Bool - public var isMine: Bool - public var createdOrder: Int - - public init( - id: UUID = UUID(), - perspectiveId: Int? = nil, - author: String, - authorImageURL: String? = nil, - timeAgo: String, - option: CommentOption, - optionLabel: String? = nil, - content: String, - replyCount: Int, - likeCount: Int, - isLiked: Bool = false, - isMine: Bool = false, - createdOrder: Int - ) { - self.id = id - self.perspectiveId = perspectiveId - self.author = author - self.authorImageURL = authorImageURL - self.timeAgo = timeAgo - self.option = option - self.optionLabel = optionLabel - self.content = content - self.replyCount = replyCount - self.likeCount = likeCount - self.isLiked = isLiked - self.isMine = isMine - self.createdOrder = createdOrder - } - - /// API 응답 BattlePerspective 를 화면 모델로 변환. - public init( - item: BattlePerspective, - order: Int - ) { - let optionFallback: CommentOption = item.option.label == "B" ? .b : .a - self.init( - id: UUID(uuidString: Self.deterministicUUID(perspectiveId: item.perspectiveId)) ?? UUID(), - perspectiveId: item.perspectiveId, - author: item.user.nickname, - authorImageURL: item.user.characterImageUrl, - timeAgo: Self.relativeTimeString(from: item.createdAt), - option: optionFallback, - optionLabel: item.option.title, - content: item.content, - replyCount: item.commentCount, - likeCount: item.likeCount, - isLiked: item.isLiked, - isMine: item.isMyPerspective, - createdOrder: order - ) - } - - private static func deterministicUUID(perspectiveId: Int) -> String { - let hex = String(format: "%012X", perspectiveId) - return "00000000-0000-0000-0000-\(hex)" - } - - private static func relativeTimeString(from date: Date?) -> String { - guard let date else { return "방금 전" } - let interval = Date().timeIntervalSince(date) - if interval < 60 { return "방금 전" } - if interval < 3600 { return "\(Int(interval / 60))분 전" } - if interval < 86400 { return "\(Int(interval / 3600))시간 전" } - return "\(Int(interval / 86400))일 전" - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/Comment.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/Comment.swift new file mode 100644 index 00000000..95e8ec4c --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/Comment.swift @@ -0,0 +1,36 @@ +// +// Comment.swift +// Entity +// +// picke.pen `댓글화면` (j3GDzL) 매핑 도메인 모델. +// + +import Foundation + +public struct Comment: Equatable, Identifiable, Hashable { + public let id: Int + public let author: CommentAuthor + public let text: String + public let createdAt: Date + public let likeCount: Int + public let replyCount: Int + public let isLiked: Bool + + public init( + id: Int, + author: CommentAuthor, + text: String, + createdAt: Date, + likeCount: Int, + replyCount: Int, + isLiked: Bool + ) { + self.id = id + self.author = author + self.text = text + self.createdAt = createdAt + self.likeCount = likeCount + self.replyCount = replyCount + self.isLiked = isLiked + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentAuthor.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentAuthor.swift new file mode 100644 index 00000000..8e381a2e --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentAuthor.swift @@ -0,0 +1,22 @@ +// +// CommentAuthor.swift +// Entity +// + +import Foundation + +public struct CommentAuthor: Equatable, Hashable { + public let name: String + public let imageURL: String? + public let optionLabel: String? + + public init( + name: String, + imageURL: String? = nil, + optionLabel: String? = nil + ) { + self.name = name + self.imageURL = imageURL + self.optionLabel = optionLabel + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentFilter.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentFilter.swift new file mode 100644 index 00000000..eae8e94f --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentFilter.swift @@ -0,0 +1,29 @@ +// +// CommentFilter.swift +// Entity +// + +import Foundation + +public enum CommentFilter: String, CaseIterable, Equatable { + case all + case optionA + case optionB + + public var title: String { + switch self { + case .all: "전체" + case .optionA: "A" + case .optionB: "B" + } + } + + /// 서버 쿼리에 보낼 optionLabel — `all` 은 nil. + public var queryLabel: String? { + switch self { + case .all: nil + case .optionA: "A" + case .optionB: "B" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentItem.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentItem.swift new file mode 100644 index 00000000..e11c8bac --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentItem.swift @@ -0,0 +1,89 @@ +// +// CommentItem.swift +// Entity +// + +import Foundation + +public struct CommentItem: Equatable, Identifiable { + public let id: UUID + public var perspectiveId: Int? + public var author: String + public var authorImageURL: String? + public var timeAgo: String + public var option: CommentOption + public var optionLabel: String? + public var content: String + public var replyCount: Int + public var likeCount: Int + public var isLiked: Bool + public var isMine: Bool + public var createdOrder: Int + + public init( + id: UUID = UUID(), + perspectiveId: Int? = nil, + author: String, + authorImageURL: String? = nil, + timeAgo: String, + option: CommentOption, + optionLabel: String? = nil, + content: String, + replyCount: Int, + likeCount: Int, + isLiked: Bool = false, + isMine: Bool = false, + createdOrder: Int + ) { + self.id = id + self.perspectiveId = perspectiveId + self.author = author + self.authorImageURL = authorImageURL + self.timeAgo = timeAgo + self.option = option + self.optionLabel = optionLabel + self.content = content + self.replyCount = replyCount + self.likeCount = likeCount + self.isLiked = isLiked + self.isMine = isMine + self.createdOrder = createdOrder + } + + /// API 응답 BattlePerspective 를 화면 모델로 변환. + public init( + item: BattlePerspective, + order: Int + ) { + let optionFallback: CommentOption = item.option.label == "B" ? .b : .a + self.init( + id: UUID(uuidString: Self.deterministicUUID(perspectiveId: item.perspectiveId)) ?? UUID(), + perspectiveId: item.perspectiveId, + author: item.user.nickname, + authorImageURL: item.user.characterImageUrl, + timeAgo: Self.relativeTimeString(from: item.createdAt), + option: optionFallback, + optionLabel: item.option.title, + content: item.content, + replyCount: item.commentCount, + likeCount: item.likeCount, + isLiked: item.isLiked, + isMine: item.isMyPerspective, + createdOrder: order + ) + } + + private static func deterministicUUID(perspectiveId: Int) -> String { + let hex = String(format: "%012X", perspectiveId) + return "00000000-0000-0000-0000-\(hex)" + } + + private static func relativeTimeString(from date: Date?) -> String { + guard let date else { return "방금 전" } + let interval = Date().timeIntervalSince(date) + if interval < 60 { return "방금 전" } + if interval < 3600 { return "\(Int(interval / 60))분 전" } + if interval < 86400 { return "\(Int(interval / 3600))시간 전" } + return "\(Int(interval / 86400))일 전" + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/CommentLikeResult.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentLikeResult.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/Comment/CommentLikeResult.swift rename to Projects/Domain/Entity/Sources/Home/Comment/Item/CommentLikeResult.swift diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentOption.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentOption.swift new file mode 100644 index 00000000..d03beef9 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentOption.swift @@ -0,0 +1,18 @@ +// +// CommentOption.swift +// Entity +// + +import Foundation + +public enum CommentOption: Equatable { + case a + case b + + public var label: String { + switch self { + case .a: "A" + case .b: "B" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentReplyItem.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentReplyItem.swift new file mode 100644 index 00000000..c317cdff --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentReplyItem.swift @@ -0,0 +1,114 @@ +// +// CommentReplyItem.swift +// Entity +// + +import Foundation + +public struct CommentReplyItem: Equatable, Identifiable { + public let id: UUID + public var commentId: Int? + public var author: String + public var authorImageURL: String? + public var timeAgo: String + public var option: CommentOption + public var content: String + public var likeCount: Int + public var isLiked: Bool + public var isMine: Bool + public var createdOrder: Int + + public init( + id: UUID = UUID(), + commentId: Int? = nil, + author: String, + authorImageURL: String? = nil, + timeAgo: String, + option: CommentOption, + content: String, + likeCount: Int, + isLiked: Bool = false, + isMine: Bool = false, + createdOrder: Int + ) { + self.id = id + self.commentId = commentId + self.author = author + self.authorImageURL = authorImageURL + self.timeAgo = timeAgo + self.option = option + self.content = content + self.likeCount = likeCount + self.isLiked = isLiked + self.isMine = isMine + self.createdOrder = createdOrder + } + + /// 서버 PerspectiveComment 응답을 화면 모델로 변환. + public init( + item: PerspectiveComment, + parentOption: CommentOption, + order: Int + ) { + let id = UUID(uuidString: Self.deterministicUUID(commentId: item.commentId)) ?? UUID() + self.init( + id: id, + commentId: item.commentId, + author: item.user.nickname, + authorImageURL: item.user.characterImageUrl, + timeAgo: Self.relativeTimeString(from: item.createdAt), + option: parentOption, + content: item.content, + likeCount: item.likeCount, + isLiked: item.isLiked, + isMine: item.isMine, + createdOrder: order + ) + } + + private static func deterministicUUID(commentId: Int) -> String { + let hex = String(format: "%012X", commentId) + return "00000000-0000-0000-0002-\(hex)" + } + + private static func relativeTimeString(from date: Date?) -> String { + guard let date else { return "방금 전" } + let interval = Date().timeIntervalSince(date) + if interval < 60 { return "방금 전" } + if interval < 3600 { return "\(Int(interval / 60))분 전" } + if interval < 86400 { return "\(Int(interval / 3600))시간 전" } + return "\(Int(interval / 86400))일 전" + } + + public static func mocks(for option: CommentOption) -> [CommentReplyItem] { + [ + .init( + id: UUID(uuidString: "00000000-0000-0000-0001-000000000001") ?? UUID(), + author: "사색하는 사슴", + timeAgo: "2분 전", + option: option, + content: "네덜란드 사례를 일반화하기엔 무리가 있지 않나요? 한국의 사회문화적 맥락은 다릅니다.", + likeCount: 1340, + createdOrder: 3 + ), + .init( + id: UUID(uuidString: "00000000-0000-0000-0001-000000000002") ?? UUID(), + author: "논쟁하는 사자", + timeAgo: "2분 전", + option: option, + content: "제도 자체보다 사각지대를 줄이는 보완책을 같이 봐야 한다고 생각해요.", + likeCount: 534, + createdOrder: 2 + ), + .init( + id: UUID(uuidString: "00000000-0000-0000-0001-000000000003") ?? UUID(), + author: "질문하는 독자", + timeAgo: "5분 전", + option: option == .a ? .b : .a, + content: "반대 입장도 이해되지만, 개인의 자기결정권을 완전히 배제하기는 어렵지 않을까요?", + likeCount: 219, + createdOrder: 1 + ), + ] + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentSort.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentSort.swift new file mode 100644 index 00000000..4a2a45f9 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentSort.swift @@ -0,0 +1,25 @@ +// +// CommentSort.swift +// Entity +// + +import Foundation + +public enum CommentSort: String, CaseIterable, Equatable { + case popular + case latest + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } + + public var perspectiveSort: BattlePerspectiveSort { + switch self { + case .popular: .popular + case .latest: .latest + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentSortType.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentSortType.swift new file mode 100644 index 00000000..d2e3165c --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentSortType.swift @@ -0,0 +1,18 @@ +// +// CommentSortType.swift +// Entity +// + +import Foundation + +public enum CommentSortType: String, Equatable, Hashable, CaseIterable { + case popular = "POPULAR" + case latest = "LATEST" + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentTab.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentTab.swift new file mode 100644 index 00000000..0e70e9e3 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentTab.swift @@ -0,0 +1,25 @@ +// +// CommentTab.swift +// Entity +// + +import Foundation + +public enum CommentTab: Equatable, Hashable { + case all + case option(label: String, title: String) + + public var title: String { + switch self { + case .all: "전체" + case let .option(_, title): title + } + } + + public var optionLabel: String? { + switch self { + case .all: nil + case let .option(label, _): label + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspective.swift b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspective.swift new file mode 100644 index 00000000..e3958c3f --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspective.swift @@ -0,0 +1,45 @@ +// +// BattlePerspective.swift +// Entity +// +// `GET /api/v1/battles/{battleId}/perspectives` 응답 도메인 모델. +// 댓글(=관점) 리스트 + 커서 페이지네이션. +// + +import Foundation + +public struct BattlePerspective: Equatable, Identifiable, Hashable { + public let perspectiveId: Int + public let user: BattlePerspectiveUser + public let option: BattlePerspectiveOption + public let content: String + public let likeCount: Int + public let commentCount: Int + public let isLiked: Bool + public let isMyPerspective: Bool + public let createdAt: Date? + + public var id: Int { perspectiveId } + + public init( + perspectiveId: Int, + user: BattlePerspectiveUser, + option: BattlePerspectiveOption, + content: String, + likeCount: Int, + commentCount: Int, + isLiked: Bool, + isMyPerspective: Bool, + createdAt: Date? + ) { + self.perspectiveId = perspectiveId + self.user = user + self.option = option + self.content = content + self.likeCount = likeCount + self.commentCount = commentCount + self.isLiked = isLiked + self.isMyPerspective = isMyPerspective + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveOption.swift b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveOption.swift new file mode 100644 index 00000000..0c909c21 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveOption.swift @@ -0,0 +1,27 @@ +// +// BattlePerspectiveOption.swift +// Entity +// + +import Foundation + +public struct BattlePerspectiveOption: Equatable, Hashable, Identifiable { + public let optionId: Int + public let label: String? + public let title: String + public let stance: String + + public var id: Int { optionId } + + public init( + optionId: Int, + label: String?, + title: String, + stance: String + ) { + self.optionId = optionId + self.label = label + self.title = title + self.stance = stance + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectivePage.swift b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectivePage.swift new file mode 100644 index 00000000..19ccb409 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectivePage.swift @@ -0,0 +1,22 @@ +// +// BattlePerspectivePage.swift +// Entity +// + +import Foundation + +public struct BattlePerspectivePage: Equatable { + public let items: [BattlePerspective] + public let nextCursor: String? + public let hasNext: Bool + + public init( + items: [BattlePerspective], + nextCursor: String?, + hasNext: Bool + ) { + self.items = items + self.nextCursor = nextCursor + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveSort.swift b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveSort.swift new file mode 100644 index 00000000..b54f2951 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveSort.swift @@ -0,0 +1,20 @@ +// +// BattlePerspectiveSort.swift +// Entity +// + +import Foundation + +public enum BattlePerspectiveSort: String, Equatable, Hashable, CaseIterable { + case popular + case latest + + public var queryValue: String { rawValue } + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveUser.swift b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveUser.swift new file mode 100644 index 00000000..cb56a35b --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveUser.swift @@ -0,0 +1,25 @@ +// +// BattlePerspectiveUser.swift +// Entity +// + +import Foundation + +public struct BattlePerspectiveUser: Equatable, Hashable { + public let userTag: String + public let nickname: String + public let characterType: String + public let characterImageUrl: String? + + public init( + userTag: String, + nickname: String, + characterType: String, + characterImageUrl: String? + ) { + self.userTag = userTag + self.nickname = nickname + self.characterType = characterType + self.characterImageUrl = characterImageUrl + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift deleted file mode 100644 index 6943d639..00000000 --- a/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// PerspectiveComment.swift -// Entity -// -// `GET /api/v1/perspectives/{perspectiveId}/comments/labeled` 등 대댓글 API 도메인 모델. -// - -import Foundation - -public struct PerspectiveCommentPage: Equatable { - public let items: [PerspectiveComment] - public let nextCursor: String? - public let hasNext: Bool - - public init( - items: [PerspectiveComment], - nextCursor: String?, - hasNext: Bool - ) { - self.items = items - self.nextCursor = nextCursor - self.hasNext = hasNext - } -} - -public struct PerspectiveComment: Equatable, Identifiable, Hashable { - public let commentId: Int - public let user: PerspectiveCommentUser - public let stance: String - public let content: String - public let likeCount: Int - public let isLiked: Bool - public let isMine: Bool - public let createdAt: Date? - - public var id: Int { commentId } - - public init( - commentId: Int, - user: PerspectiveCommentUser, - stance: String, - content: String, - likeCount: Int, - isLiked: Bool, - isMine: Bool, - createdAt: Date? - ) { - self.commentId = commentId - self.user = user - self.stance = stance - self.content = content - self.likeCount = likeCount - self.isLiked = isLiked - self.isMine = isMine - self.createdAt = createdAt - } -} - -public struct PerspectiveCommentUser: Equatable, Hashable { - public let userTag: String - public let nickname: String - public let characterType: String - public let characterImageUrl: String? - - public init( - userTag: String, - nickname: String, - characterType: String, - characterImageUrl: String? - ) { - self.userTag = userTag - self.nickname = nickname - self.characterType = characterType - self.characterImageUrl = characterImageUrl - } -} - -/// 대댓글 작성/수정 응답. -public struct PerspectiveCommentMutationResult: Equatable, Hashable { - public let commentId: Int - public let content: String - public let updatedAt: Date? - - public init( - commentId: Int, - content: String, - updatedAt: Date? - ) { - self.commentId = commentId - self.content = content - self.updatedAt = updatedAt - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveComment.swift b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveComment.swift new file mode 100644 index 00000000..3140025e --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveComment.swift @@ -0,0 +1,41 @@ +// +// PerspectiveComment.swift +// Entity +// +// `GET /api/v1/perspectives/{perspectiveId}/comments/labeled` 등 대댓글 API 도메인 모델. +// + +import Foundation + +public struct PerspectiveComment: Equatable, Identifiable, Hashable { + public let commentId: Int + public let user: PerspectiveCommentUser + public let stance: String + public let content: String + public let likeCount: Int + public let isLiked: Bool + public let isMine: Bool + public let createdAt: Date? + + public var id: Int { commentId } + + public init( + commentId: Int, + user: PerspectiveCommentUser, + stance: String, + content: String, + likeCount: Int, + isLiked: Bool, + isMine: Bool, + createdAt: Date? + ) { + self.commentId = commentId + self.user = user + self.stance = stance + self.content = content + self.likeCount = likeCount + self.isLiked = isLiked + self.isMine = isMine + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentMutationResult.swift b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentMutationResult.swift new file mode 100644 index 00000000..76f0b988 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentMutationResult.swift @@ -0,0 +1,23 @@ +// +// PerspectiveCommentMutationResult.swift +// Entity +// + +import Foundation + +/// 대댓글 작성/수정 응답. +public struct PerspectiveCommentMutationResult: Equatable, Hashable { + public let commentId: Int + public let content: String + public let updatedAt: Date? + + public init( + commentId: Int, + content: String, + updatedAt: Date? + ) { + self.commentId = commentId + self.content = content + self.updatedAt = updatedAt + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentPage.swift b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentPage.swift new file mode 100644 index 00000000..9c330518 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentPage.swift @@ -0,0 +1,22 @@ +// +// PerspectiveCommentPage.swift +// Entity +// + +import Foundation + +public struct PerspectiveCommentPage: Equatable { + public let items: [PerspectiveComment] + public let nextCursor: String? + public let hasNext: Bool + + public init( + items: [PerspectiveComment], + nextCursor: String?, + hasNext: Bool + ) { + self.items = items + self.nextCursor = nextCursor + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentUser.swift b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentUser.swift new file mode 100644 index 00000000..bf141d6c --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentUser.swift @@ -0,0 +1,25 @@ +// +// PerspectiveCommentUser.swift +// Entity +// + +import Foundation + +public struct PerspectiveCommentUser: Equatable, Hashable { + public let userTag: String + public let nickname: String + public let characterType: String + public let characterImageUrl: String? + + public init( + userTag: String, + nickname: String, + characterType: String, + characterImageUrl: String? + ) { + self.userTag = userTag + self.nickname = nickname + self.characterType = characterType + self.characterImageUrl = characterImageUrl + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift b/Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift index 92dd94ec..58bac6f7 100644 --- a/Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift +++ b/Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift @@ -25,26 +25,3 @@ public struct BattleTag: Equatable, Identifiable, Hashable { self.type = type } } - -public enum TagType: String, Equatable, Hashable, Decodable { - case philosopher = "PHILOSOPHER" - case category = "CATEGORY" - case era = "ERA" - case value = "VALUE" - case unknown - - public init(rawValue: String) { - switch rawValue { - case "PHILOSOPHER": self = .philosopher - case "CATEGORY": self = .category - case "ERA": self = .era - case "VALUE": self = .value - default: self = .unknown - } - } - - public init(from decoder: Decoder) throws { - let raw = try decoder.singleValueContainer().decode(String.self) - self = TagType(rawValue: raw) - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Core/TagType.swift b/Projects/Domain/Entity/Sources/Home/Core/TagType.swift new file mode 100644 index 00000000..2f0e6b4c --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Core/TagType.swift @@ -0,0 +1,29 @@ +// +// TagType.swift +// Entity +// + +import Foundation + +public enum TagType: String, Equatable, Hashable, Decodable { + case philosopher = "PHILOSOPHER" + case category = "CATEGORY" + case era = "ERA" + case value = "VALUE" + case unknown + + public init(rawValue: String) { + switch rawValue { + case "PHILOSOPHER": self = .philosopher + case "CATEGORY": self = .category + case "ERA": self = .era + case "VALUE": self = .value + default: self = .unknown + } + } + + public init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = TagType(rawValue: raw) + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Explore/ExploreCategory.swift b/Projects/Domain/Entity/Sources/Home/Explore/ExploreCategory.swift new file mode 100644 index 00000000..6ea3048f --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Explore/ExploreCategory.swift @@ -0,0 +1,38 @@ +// +// ExploreCategory.swift +// Entity +// + +import Foundation + +public enum ExploreCategory: String, Equatable, Hashable, CaseIterable, Identifiable { + case all + case philosophy + case literature + case art + case science + case society + case history + + public var id: String { rawValue } + + public var title: String { + switch self { + case .all: "전체" + case .philosophy: "철학" + case .literature: "문학" + case .art: "예술" + case .science: "과학" + case .society: "사회" + case .history: "역사" + } + } + + /// 검색 API `category` 쿼리 값 (대문자 영문 enum). 전체는 nil(필터 없음). + public var queryValue: String? { + switch self { + case .all: nil + default: rawValue.uppercased() + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Explore/ExploreItem.swift b/Projects/Domain/Entity/Sources/Home/Explore/ExploreItem.swift index 0199d0a8..8ca57ea8 100644 --- a/Projects/Domain/Entity/Sources/Home/Explore/ExploreItem.swift +++ b/Projects/Domain/Entity/Sources/Home/Explore/ExploreItem.swift @@ -7,22 +7,6 @@ import Foundation -public struct ExploreItemPage: Equatable { - public let items: [ExploreItem] - public let nextOffset: Int? - public let hasNext: Bool - - public init( - items: [ExploreItem], - nextOffset: Int?, - hasNext: Bool - ) { - self.items = items - self.nextOffset = nextOffset - self.hasNext = hasNext - } -} - public struct ExploreItem: Equatable, Identifiable, Hashable { public let id: Int public let category: String @@ -50,50 +34,3 @@ public struct ExploreItem: Equatable, Identifiable, Hashable { self.imageURL = imageURL } } - -public enum ExploreCategory: String, Equatable, Hashable, CaseIterable, Identifiable { - case all - case philosophy - case literature - case art - case science - case society - case history - - public var id: String { rawValue } - - public var title: String { - switch self { - case .all: "전체" - case .philosophy: "철학" - case .literature: "문학" - case .art: "예술" - case .science: "과학" - case .society: "사회" - case .history: "역사" - } - } - - /// 검색 API `category` 쿼리 값 (대문자 영문 enum). 전체는 nil(필터 없음). - public var queryValue: String? { - switch self { - case .all: nil - default: rawValue.uppercased() - } - } -} - -public enum ExploreSort: String, Equatable, Hashable, CaseIterable { - case popular - case latest - - public var title: String { - switch self { - case .popular: "인기순" - case .latest: "최신순" - } - } - - /// 검색 API `sort` 쿼리 값 (대문자 영문 enum: POPULAR / LATEST). - public var queryValue: String { rawValue.uppercased() } -} diff --git a/Projects/Domain/Entity/Sources/Home/Explore/ExploreItemPage.swift b/Projects/Domain/Entity/Sources/Home/Explore/ExploreItemPage.swift new file mode 100644 index 00000000..8d3a723b --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Explore/ExploreItemPage.swift @@ -0,0 +1,22 @@ +// +// ExploreItemPage.swift +// Entity +// + +import Foundation + +public struct ExploreItemPage: Equatable { + public let items: [ExploreItem] + public let nextOffset: Int? + public let hasNext: Bool + + public init( + items: [ExploreItem], + nextOffset: Int?, + hasNext: Bool + ) { + self.items = items + self.nextOffset = nextOffset + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Explore/ExploreSort.swift b/Projects/Domain/Entity/Sources/Home/Explore/ExploreSort.swift new file mode 100644 index 00000000..12a1a556 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Explore/ExploreSort.swift @@ -0,0 +1,21 @@ +// +// ExploreSort.swift +// Entity +// + +import Foundation + +public enum ExploreSort: String, Equatable, Hashable, CaseIterable { + case popular + case latest + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } + + /// 검색 API `sort` 쿼리 값 (대문자 영문 enum: POPULAR / LATEST). + public var queryValue: String { rawValue.uppercased() } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift index 55d100e9..a2a3122b 100644 --- a/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift +++ b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift @@ -39,111 +39,3 @@ public struct BattleScenario: Equatable, Identifiable { self.nodes = nodes } } - -public struct ScenarioPhilosopher: Equatable, Hashable, Identifiable { - public let label: String - public let name: String - public let stance: String - public let imageUrl: String - - public var id: String { label } - - public init( - label: String, - name: String, - stance: String, - imageUrl: String - ) { - self.label = label - self.name = name - self.stance = stance - self.imageUrl = imageUrl - } -} - -public struct ScenarioNode: Equatable, Identifiable { - public let nodeId: Int - public let nodeName: String - public let audioDuration: Int - public let autoNextNodeId: Int? - public let scripts: [ScenarioScript] - public let interactiveOptions: [ScenarioInteractiveOption] - - public var id: Int { nodeId } - - public init( - nodeId: Int, - nodeName: String, - audioDuration: Int, - autoNextNodeId: Int?, - scripts: [ScenarioScript], - interactiveOptions: [ScenarioInteractiveOption] - ) { - self.nodeId = nodeId - self.nodeName = nodeName - self.audioDuration = audioDuration - self.autoNextNodeId = autoNextNodeId - self.scripts = scripts - self.interactiveOptions = interactiveOptions - } -} - -public struct ScenarioScript: Equatable, Identifiable, Hashable { - public let scriptId: Int - public let startTimeMs: Int - public let speakerType: ScenarioSpeakerType - public let speakerName: String - public let text: String - - public var id: Int { scriptId } - - public init( - scriptId: Int, - startTimeMs: Int, - speakerType: ScenarioSpeakerType, - speakerName: String, - text: String - ) { - self.scriptId = scriptId - self.startTimeMs = startTimeMs - self.speakerType = speakerType - self.speakerName = speakerName - self.text = text - } -} - -public struct ScenarioInteractiveOption: Equatable, Hashable, Identifiable { - public let label: String - public let nextNodeId: Int - - public var id: String { "\(label)-\(nextNodeId)" } - - public init( - label: String, - nextNodeId: Int - ) { - self.label = label - self.nextNodeId = nextNodeId - } -} - -public enum ScenarioSpeakerType: String, Equatable, Hashable, CaseIterable { - case a = "A" - case b = "B" - case narrator = "NARRATOR" - case philosopher = "PHILOSOPHER" - case unknown - - public init(rawValue: String) { - self = ScenarioSpeakerType.allCases.first { $0.rawValue == rawValue } ?? .unknown - } -} - -public enum RecommendedPathKey: String, Equatable, Hashable, CaseIterable { - case common = "COMMON" - case unknown - - public init(rawValue: String) { - self = RecommendedPathKey.allCases.first { $0.rawValue == rawValue } ?? .unknown - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatMessage.swift b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatMessage.swift new file mode 100644 index 00000000..c54b6b50 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatMessage.swift @@ -0,0 +1,32 @@ +// +// ChatMessage.swift +// Entity +// +// 채팅방(`/Users/suhwonji/Desktop/와이어프레임/채팅방.pdf` + .pen `k3lIx`) 메시지 모델. +// + +import Foundation + +public struct ChatMessage: Equatable, Identifiable, Hashable { + public let messageId: UUID + public let speaker: ChatSpeaker + public let text: String + /// 시나리오 스크립트의 시작 시각 (밀리초). 오디오 재생 진행도에 따라 + /// 활성 메시지로 자동 스크롤할 때 사용. mock 데이터 / 시간 정보가 없는 + /// 경우엔 nil. + public let startTimeMs: Int? + + public var id: UUID { messageId } + + public init( + messageId: UUID = UUID(), + speaker: ChatSpeaker, + text: String, + startTimeMs: Int? = nil + ) { + self.messageId = messageId + self.speaker = speaker + self.text = text + self.startTimeMs = startTimeMs + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/ChatMessage.swift b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatRoomBundle.swift similarity index 65% rename from Projects/Domain/Entity/Sources/Home/Scenario/ChatMessage.swift rename to Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatRoomBundle.swift index 9c55d512..fdc4aafb 100644 --- a/Projects/Domain/Entity/Sources/Home/Scenario/ChatMessage.swift +++ b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatRoomBundle.swift @@ -1,63 +1,10 @@ // -// ChatMessage.swift +// ChatRoomBundle.swift // Entity // -// 채팅방(`/Users/suhwonji/Desktop/와이어프레임/채팅방.pdf` + .pen `k3lIx`) 메시지 모델. -// import Foundation -public enum ChatSpeakerSide: Equatable, Hashable { - case left - case right - case center -} - -public struct ChatSpeaker: Equatable, Identifiable, Hashable { - public let label: String? - public let name: String - public let imageURL: String? - public let side: ChatSpeakerSide - - public var id: String { "\(label ?? name)-\(side)" } - - public init( - label: String? = nil, - name: String, - imageURL: String? = nil, - side: ChatSpeakerSide - ) { - self.label = label - self.name = name - self.imageURL = imageURL - self.side = side - } -} - -public struct ChatMessage: Equatable, Identifiable, Hashable { - public let messageId: UUID - public let speaker: ChatSpeaker - public let text: String - /// 시나리오 스크립트의 시작 시각 (밀리초). 오디오 재생 진행도에 따라 - /// 활성 메시지로 자동 스크롤할 때 사용. mock 데이터 / 시간 정보가 없는 - /// 경우엔 nil. - public let startTimeMs: Int? - - public var id: UUID { messageId } - - public init( - messageId: UUID = UUID(), - speaker: ChatSpeaker, - text: String, - startTimeMs: Int? = nil - ) { - self.messageId = messageId - self.speaker = speaker - self.text = text - self.startTimeMs = startTimeMs - } -} - public struct ChatRoomBundle: Equatable { public let battleTitle: String public let totalDuration: TimeInterval diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatSpeaker.swift b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatSpeaker.swift new file mode 100644 index 00000000..c9c5f2d1 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatSpeaker.swift @@ -0,0 +1,27 @@ +// +// ChatSpeaker.swift +// Entity +// + +import Foundation + +public struct ChatSpeaker: Equatable, Identifiable, Hashable { + public let label: String? + public let name: String + public let imageURL: String? + public let side: ChatSpeakerSide + + public var id: String { "\(label ?? name)-\(side)" } + + public init( + label: String? = nil, + name: String, + imageURL: String? = nil, + side: ChatSpeakerSide + ) { + self.label = label + self.name = name + self.imageURL = imageURL + self.side = side + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatSpeakerSide.swift b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatSpeakerSide.swift new file mode 100644 index 00000000..fdf327f7 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatSpeakerSide.swift @@ -0,0 +1,12 @@ +// +// ChatSpeakerSide.swift +// Entity +// + +import Foundation + +public enum ChatSpeakerSide: Equatable, Hashable { + case left + case right + case center +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/RecommendedPathKey.swift b/Projects/Domain/Entity/Sources/Home/Scenario/RecommendedPathKey.swift new file mode 100644 index 00000000..3c86032a --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/RecommendedPathKey.swift @@ -0,0 +1,15 @@ +// +// RecommendedPathKey.swift +// Entity +// + +import Foundation + +public enum RecommendedPathKey: String, Equatable, Hashable, CaseIterable { + case common = "COMMON" + case unknown + + public init(rawValue: String) { + self = RecommendedPathKey.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioInteractiveOption.swift b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioInteractiveOption.swift new file mode 100644 index 00000000..289288f4 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioInteractiveOption.swift @@ -0,0 +1,21 @@ +// +// ScenarioInteractiveOption.swift +// Entity +// + +import Foundation + +public struct ScenarioInteractiveOption: Equatable, Hashable, Identifiable { + public let label: String + public let nextNodeId: Int + + public var id: String { "\(label)-\(nextNodeId)" } + + public init( + label: String, + nextNodeId: Int + ) { + self.label = label + self.nextNodeId = nextNodeId + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioNode.swift b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioNode.swift new file mode 100644 index 00000000..e8c45f76 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioNode.swift @@ -0,0 +1,33 @@ +// +// ScenarioNode.swift +// Entity +// + +import Foundation + +public struct ScenarioNode: Equatable, Identifiable { + public let nodeId: Int + public let nodeName: String + public let audioDuration: Int + public let autoNextNodeId: Int? + public let scripts: [ScenarioScript] + public let interactiveOptions: [ScenarioInteractiveOption] + + public var id: Int { nodeId } + + public init( + nodeId: Int, + nodeName: String, + audioDuration: Int, + autoNextNodeId: Int?, + scripts: [ScenarioScript], + interactiveOptions: [ScenarioInteractiveOption] + ) { + self.nodeId = nodeId + self.nodeName = nodeName + self.audioDuration = audioDuration + self.autoNextNodeId = autoNextNodeId + self.scripts = scripts + self.interactiveOptions = interactiveOptions + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioPhilosopher.swift b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioPhilosopher.swift new file mode 100644 index 00000000..46b4f6c6 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioPhilosopher.swift @@ -0,0 +1,27 @@ +// +// ScenarioPhilosopher.swift +// Entity +// + +import Foundation + +public struct ScenarioPhilosopher: Equatable, Hashable, Identifiable { + public let label: String + public let name: String + public let stance: String + public let imageUrl: String + + public var id: String { label } + + public init( + label: String, + name: String, + stance: String, + imageUrl: String + ) { + self.label = label + self.name = name + self.stance = stance + self.imageUrl = imageUrl + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioScript.swift b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioScript.swift new file mode 100644 index 00000000..e02e1a7a --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioScript.swift @@ -0,0 +1,30 @@ +// +// ScenarioScript.swift +// Entity +// + +import Foundation + +public struct ScenarioScript: Equatable, Identifiable, Hashable { + public let scriptId: Int + public let startTimeMs: Int + public let speakerType: ScenarioSpeakerType + public let speakerName: String + public let text: String + + public var id: Int { scriptId } + + public init( + scriptId: Int, + startTimeMs: Int, + speakerType: ScenarioSpeakerType, + speakerName: String, + text: String + ) { + self.scriptId = scriptId + self.startTimeMs = startTimeMs + self.speakerType = speakerType + self.speakerName = speakerName + self.text = text + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioSpeakerType.swift b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioSpeakerType.swift new file mode 100644 index 00000000..cf769402 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioSpeakerType.swift @@ -0,0 +1,18 @@ +// +// ScenarioSpeakerType.swift +// Entity +// + +import Foundation + +public enum ScenarioSpeakerType: String, Equatable, Hashable, CaseIterable { + case a = "A" + case b = "B" + case narrator = "NARRATOR" + case philosopher = "PHILOSOPHER" + case unknown + + public init(rawValue: String) { + self = ScenarioSpeakerType.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Section/BestBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/Battle/BestBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/Section/BestBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/Battle/BestBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/Section/HeroBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/Battle/HeroBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/Section/HeroBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/Battle/HeroBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/Section/HotBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/Battle/HotBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/Section/HotBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/Battle/HotBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/Section/NewBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/Battle/NewBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/Section/NewBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/Battle/NewBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/Section/Vote/VoteOption.swift b/Projects/Domain/Entity/Sources/Home/Section/Vote/VoteOption.swift new file mode 100644 index 00000000..5a382686 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Section/Vote/VoteOption.swift @@ -0,0 +1,21 @@ +// +// VoteOption.swift +// Entity +// + +import Foundation + +public struct VoteOption: Equatable, Identifiable, Hashable { + public let label: String + public let title: String + + public var id: String { label } + + public init( + label: String, + title: String + ) { + self.label = label + self.title = title + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Section/VoteQuestion.swift b/Projects/Domain/Entity/Sources/Home/Section/Vote/VoteQuestion.swift similarity index 83% rename from Projects/Domain/Entity/Sources/Home/Section/VoteQuestion.swift rename to Projects/Domain/Entity/Sources/Home/Section/Vote/VoteQuestion.swift index be4ae3b2..e191f26c 100644 --- a/Projects/Domain/Entity/Sources/Home/Section/VoteQuestion.swift +++ b/Projects/Domain/Entity/Sources/Home/Section/Vote/VoteQuestion.swift @@ -39,21 +39,6 @@ public struct VoteQuestion: Equatable, Identifiable { public var suffix: String { titleSuffix } } -public struct VoteOption: Equatable, Identifiable, Hashable { - public let label: String - public let title: String - - public var id: String { label } - - public init( - label: String, - title: String - ) { - self.label = label - self.title = title - } -} - public extension VoteQuestion { static let mock = VoteQuestion( battleId: 41, diff --git a/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleRecord.swift b/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleRecord.swift new file mode 100644 index 00000000..6490914e --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleRecord.swift @@ -0,0 +1,41 @@ +// +// BattleRecord.swift +// Entity +// + +import Foundation + +public struct BattleRecord: Equatable, Identifiable { + public let battleId: String + public let recordId: String + public let voteSide: BattleVoteSide + public let category: String + public let title: String + public let summary: String + public let createdAt: Date? + + public var id: String { recordId } + + /// 카테고리 태그 표시 (예: `#철학`). + public var categoryTag: String { + category.isEmpty ? "" : "#\(category)" + } + + public init( + battleId: String, + recordId: String, + voteSide: BattleVoteSide, + category: String, + title: String, + summary: String, + createdAt: Date? + ) { + self.battleId = battleId + self.recordId = recordId + self.voteSide = voteSide + self.category = category + self.title = title + self.summary = summary + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleRecordPage.swift b/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleRecordPage.swift new file mode 100644 index 00000000..b96171d8 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleRecordPage.swift @@ -0,0 +1,24 @@ +// +// BattleRecordPage.swift +// Entity +// +// `GET /api/v1/me/battle-records` 응답 (offset 페이지네이션). +// + +import Foundation + +public struct BattleRecordPage: Equatable { + public let items: [BattleRecord] + public let nextOffset: Int + public let hasNext: Bool + + public init( + items: [BattleRecord], + nextOffset: Int, + hasNext: Bool + ) { + self.items = items + self.nextOffset = nextOffset + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleVoteSide.swift b/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleVoteSide.swift new file mode 100644 index 00000000..084980de --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleVoteSide.swift @@ -0,0 +1,21 @@ +// +// BattleVoteSide.swift +// Entity +// + +import Foundation + +/// 투표 진영 (PRO=찬성 / CON=반대). +public enum BattleVoteSide: String, Equatable { + case pro = "PRO" + case con = "CON" + case unknown = "" + + public init(rawValue: String) { + switch rawValue.uppercased() { + case "PRO": self = .pro + case "CON": self = .con + default: self = .unknown + } + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/CreditHistory.swift b/Projects/Domain/Entity/Sources/Profile/Credit/CreditHistoryItem.swift similarity index 71% rename from Projects/Domain/Entity/Sources/Profile/CreditHistory.swift rename to Projects/Domain/Entity/Sources/Profile/Credit/CreditHistoryItem.swift index 400a7f56..93ace99d 100644 --- a/Projects/Domain/Entity/Sources/Profile/CreditHistory.swift +++ b/Projects/Domain/Entity/Sources/Profile/Credit/CreditHistoryItem.swift @@ -1,29 +1,10 @@ // -// CreditHistory.swift +// CreditHistoryItem.swift // Entity // -// `GET /api/v1/me/credits/history` 응답 도메인 모델. -// 크레딧(포인트) 지급/사용 내역 + offset 기반 페이지네이션. -// import Foundation -public struct CreditHistoryPage: Equatable { - public let items: [CreditHistoryItem] - public let nextOffset: Int - public let hasNext: Bool - - public init( - items: [CreditHistoryItem], - nextOffset: Int, - hasNext: Bool - ) { - self.items = items - self.nextOffset = nextOffset - self.hasNext = hasNext - } -} - public struct CreditHistoryItem: Equatable, Identifiable { public let id: Int /// 크레딧 유형 코드 (예: `TODAY_CREDIT`). diff --git a/Projects/Domain/Entity/Sources/Profile/Credit/CreditHistoryPage.swift b/Projects/Domain/Entity/Sources/Profile/Credit/CreditHistoryPage.swift new file mode 100644 index 00000000..8f73feda --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Credit/CreditHistoryPage.swift @@ -0,0 +1,24 @@ +// +// CreditHistoryPage.swift +// Entity +// +// `GET /api/v1/me/credits/history` 응답 (offset 페이지네이션). +// + +import Foundation + +public struct CreditHistoryPage: Equatable { + public let items: [CreditHistoryItem] + public let nextOffset: Int + public let hasNext: Bool + + public init( + items: [CreditHistoryItem], + nextOffset: Int, + hasNext: Bool + ) { + self.items = items + self.nextOffset = nextOffset + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/MyPage.swift b/Projects/Domain/Entity/Sources/Profile/MyPage.swift deleted file mode 100644 index 5b315c2d..00000000 --- a/Projects/Domain/Entity/Sources/Profile/MyPage.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// MyPage.swift -// Entity -// -// `GET /api/v1/me/mypage` 응답 도메인 모델. -// 프로필 / 철학자 유형 / 티어(포인트) 3개 섹션. -// - -import Foundation - -public struct MyPage: Equatable { - public let profile: MyProfile - public let philosopher: MyPhilosopher - public let tier: MyTier - - public init( - profile: MyProfile, - philosopher: MyPhilosopher, - tier: MyTier - ) { - self.profile = profile - self.philosopher = philosopher - self.tier = tier - } -} - -public struct MyProfile: Equatable { - /// 사용자 코드 (`@` 표기에 사용). - public let userTag: String - public let nickname: String - /// 캐릭터 유형 코드 (예: `OWL`). - public let characterType: String - public let characterLabel: String - public let characterImageURL: String - /// 매너 온도. - public let mannerTemperature: Double - - public init( - userTag: String, - nickname: String, - characterType: String, - characterLabel: String, - characterImageURL: String, - mannerTemperature: Double - ) { - self.userTag = userTag - self.nickname = nickname - self.characterType = characterType - self.characterLabel = characterLabel - self.characterImageURL = characterImageURL - self.mannerTemperature = mannerTemperature - } -} - -public struct MyPhilosopher: Equatable { - /// 철학자 유형 코드 (예: `SOCRATES`). - public let philosopherType: String - public let philosopherLabel: String - /// 유형명 (예: `??형`). - public let typeName: String - public let description: String - public let imageURL: String - - public init( - philosopherType: String, - philosopherLabel: String, - typeName: String, - description: String, - imageURL: String - ) { - self.philosopherType = philosopherType - self.philosopherLabel = philosopherLabel - self.typeName = typeName - self.description = description - self.imageURL = imageURL - } -} - -public struct MyTier: Equatable { - /// 티어 코드 (예: `WANDERER`). - public let tierCode: String - public let tierLabel: String - /// 보유 포인트. - public let currentPoint: Int - - public init( - tierCode: String, - tierLabel: String, - currentPoint: Int - ) { - self.tierCode = tierCode - self.tierLabel = tierLabel - self.currentPoint = currentPoint - } -} diff --git a/Projects/Domain/Entity/Sources/Profile/MyPage/MyPage.swift b/Projects/Domain/Entity/Sources/Profile/MyPage/MyPage.swift new file mode 100644 index 00000000..59e79e92 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/MyPage/MyPage.swift @@ -0,0 +1,24 @@ +// +// MyPage.swift +// Entity +// +// `GET /api/v1/me/mypage` 응답 도메인 모델 (프로필 / 철학자 / 티어). +// + +import Foundation + +public struct MyPage: Equatable { + public let profile: MyProfile + public let philosopher: MyPhilosopher + public let tier: MyTier + + public init( + profile: MyProfile, + philosopher: MyPhilosopher, + tier: MyTier + ) { + self.profile = profile + self.philosopher = philosopher + self.tier = tier + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/MyPage/MyPhilosopher.swift b/Projects/Domain/Entity/Sources/Profile/MyPage/MyPhilosopher.swift new file mode 100644 index 00000000..d0db03e8 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/MyPage/MyPhilosopher.swift @@ -0,0 +1,30 @@ +// +// MyPhilosopher.swift +// Entity +// + +import Foundation + +public struct MyPhilosopher: Equatable { + /// 철학자 유형 코드 (예: `SOCRATES`). + public let philosopherType: String + public let philosopherLabel: String + /// 유형명 (예: `??형`). + public let typeName: String + public let description: String + public let imageURL: String + + public init( + philosopherType: String, + philosopherLabel: String, + typeName: String, + description: String, + imageURL: String + ) { + self.philosopherType = philosopherType + self.philosopherLabel = philosopherLabel + self.typeName = typeName + self.description = description + self.imageURL = imageURL + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/MyPage/MyProfile.swift b/Projects/Domain/Entity/Sources/Profile/MyPage/MyProfile.swift new file mode 100644 index 00000000..7d69db28 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/MyPage/MyProfile.swift @@ -0,0 +1,34 @@ +// +// MyProfile.swift +// Entity +// + +import Foundation + +public struct MyProfile: Equatable { + /// 사용자 코드 (`@` 표기에 사용). + public let userTag: String + public let nickname: String + /// 캐릭터 유형 코드 (예: `OWL`). + public let characterType: String + public let characterLabel: String + public let characterImageURL: String + /// 매너 온도. + public let mannerTemperature: Double + + public init( + userTag: String, + nickname: String, + characterType: String, + characterLabel: String, + characterImageURL: String, + mannerTemperature: Double + ) { + self.userTag = userTag + self.nickname = nickname + self.characterType = characterType + self.characterLabel = characterLabel + self.characterImageURL = characterImageURL + self.mannerTemperature = mannerTemperature + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/MyPage/MyTier.swift b/Projects/Domain/Entity/Sources/Profile/MyPage/MyTier.swift new file mode 100644 index 00000000..ed4cdfd3 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/MyPage/MyTier.swift @@ -0,0 +1,24 @@ +// +// MyTier.swift +// Entity +// + +import Foundation + +public struct MyTier: Equatable { + /// 티어 코드 (예: `WANDERER`). + public let tierCode: String + public let tierLabel: String + /// 보유 포인트. + public let currentPoint: Int + + public init( + tierCode: String, + tierLabel: String, + currentPoint: Int + ) { + self.tierCode = tierCode + self.tierLabel = tierLabel + self.currentPoint = currentPoint + } +} diff --git a/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift index 179a5f14..b0b7e8e2 100644 --- a/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift +++ b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift @@ -25,6 +25,18 @@ public struct ProfileUseCaseImpl: ProfileInterface { ) async throws -> CreditHistoryPage { return try await profileRepository.fetchCreditHistory(offset: offset, size: size) } + + public func fetchBattleRecords( + offset: Int, + size: Int, + voteSide: BattleVoteSide? + ) async throws -> BattleRecordPage { + return try await profileRepository.fetchBattleRecords( + offset: offset, + size: size, + voteSide: voteSide + ) + } } extension ProfileUseCaseImpl: DependencyKey { diff --git a/Projects/Presentation/Profile/Sources/BattleRecord/Reducer/BattleRecordFeature.swift b/Projects/Presentation/Profile/Sources/BattleRecord/Reducer/BattleRecordFeature.swift new file mode 100644 index 00000000..1f574052 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/BattleRecord/Reducer/BattleRecordFeature.swift @@ -0,0 +1,172 @@ +// +// BattleRecordFeature.swift +// Profile +// +// 내 배틀 기록 — picke.pen `내 배틀기록`. +// GET /api/v1/me/battle-records (offset 기반 페이지네이션). +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro +import UseCase + +@Reducer +public struct BattleRecordFeature { + public init() {} + + static let pageSize = 20 + + @ObservableState + public struct State: Equatable { + public var isLoading: Bool = false + public var isLoadingMore: Bool = false + public var items: [BattleRecord] = [] + public var nextOffset: Int = 0 + public var hasNext: Bool = false + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backTapped + case reachedBottom + case recordTapped(BattleRecord) + } + + public enum AsyncAction: Equatable { + case fetch(reset: Bool) + } + + public enum InnerAction: Equatable { + case recordsResponse(Result, reset: Bool) + } + + public enum DelegateAction: Equatable { + case dismiss + /// 기록 탭 → 배틀 진입. + case openRecord(battleId: String) + } + + nonisolated enum CancelID: Hashable { + case fetch + } + + @Dependency(\.profileUseCase) private var profileUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension BattleRecordFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard state.items.isEmpty else { return .none } + return .send(.async(.fetch(reset: true))) + + case .backTapped: + return .send(.delegate(.dismiss)) + + case .reachedBottom: + guard state.hasNext, !state.isLoadingMore, !state.isLoading else { return .none } + return .send(.async(.fetch(reset: false))) + + case let .recordTapped(record): + return .send(.delegate(.openRecord(battleId: record.battleId))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case let .fetch(reset): + if reset { + state.isLoading = true + } else { + state.isLoadingMore = true + } + let offset = reset ? 0 : state.nextOffset + return .run { [useCase = profileUseCase] send in + let result = await Result { + try await useCase.fetchBattleRecords(offset: offset, size: Self.pageSize, voteSide: nil) + } + .mapError(ProfileError.from) + return await send(.inner(.recordsResponse(result, reset: reset))) + } + .cancellable(id: CancelID.fetch, cancelInFlight: reset) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .recordsResponse(result, reset): + state.isLoading = false + state.isLoadingMore = false + switch result { + case let .success(page): + if reset { + state.items = page.items + } else { + state.items.append(contentsOf: page.items) + } + state.nextOffset = page.nextOffset + state.hasNext = page.hasNext + case let .failure(error): + Log.error("[BattleRecordFeature] fetchBattleRecords failed: \(error.localizedDescription)") + } + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + case .openRecord: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordSkeletonView.swift b/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordSkeletonView.swift new file mode 100644 index 00000000..0eae63e0 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordSkeletonView.swift @@ -0,0 +1,83 @@ +// +// BattleRecordSkeletonView.swift +// Profile +// +// 내 배틀 기록 로딩 스켈레톤 — 라이트(beige) shimmer. +// + +import SwiftUI + +import DesignSystem + +struct BattleRecordSkeletonView: View { + var body: some View { + ScrollView { + VStack(spacing: 12) { + ForEach(0 ..< 6, id: \.self) { _ in + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + block(width: 40, height: 18) + block(width: 150, height: 16) + } + block(width: nil, maxWidth: true, height: 32) + block(width: 72, height: 12) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.beige600, lineWidth: 1) + ) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + } + + @ViewBuilder + private func block( + width: CGFloat?, + maxWidth: Bool = false, + height: CGFloat + ) -> some View { + LightSkeletonBlock(cornerRadius: 4) + .frame(width: width) + .frame(maxWidth: maxWidth ? .infinity : nil) + .frame(height: height) + } +} + +/// 라이트 배경용 skeleton 블록. +private struct LightSkeletonBlock: View { + let cornerRadius: CGFloat + + @State private var phase: CGFloat = -1 + + private let baseColor = Color.black.opacity(0.06) + private let shimmerColor = Color.white.opacity(0.55) + + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(baseColor) + .overlay { + LinearGradient( + stops: [ + .init(color: shimmerColor.opacity(0), location: 0), + .init(color: shimmerColor, location: 0.5), + .init(color: shimmerColor.opacity(0), location: 1), + ], + startPoint: UnitPoint(x: phase, y: 0.5), + endPoint: UnitPoint(x: phase + 1, y: 0.5) + ) + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .onAppear { + withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { + phase = 2 + } + } + } +} diff --git a/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordView.swift b/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordView.swift new file mode 100644 index 00000000..fffacd71 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordView.swift @@ -0,0 +1,120 @@ +// +// BattleRecordView.swift +// Profile +// +// 내 배틀 기록 UI — picke.pen `내 배틀기록`. +// App Bar + 기록 카드(뱃지/제목/요약/날짜) 리스트 + 무한 스크롤. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity +import Utill + +@ViewAction(for: BattleRecordFeature.self) +public struct BattleRecordView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "내 배틀 기록") + .foregroundStyle(.gray500) + + if store.isLoading { + BattleRecordSkeletonView() + } else { + content() + } + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + } +} + +private extension BattleRecordView { + @ViewBuilder + func content() -> some View { + if store.items.isEmpty { + emptyState() + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(store.items) { record in + recordCard(record) + .onAppear { + if record.id == store.items.last?.id { + send(.reachedBottom) + } + } + } + + if store.isLoadingMore { + ProgressView() + .padding(.vertical, 8) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + } + } + + @ViewBuilder + func recordCard(_ record: BattleRecord) -> some View { + Button { + send(.recordTapped(record)) + } label: { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + if !record.categoryTag.isEmpty { + Text(record.categoryTag) + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.primary500) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + } + + Text(record.title) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.gray500) + .lineLimit(1) + } + + Text(record.summary) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.gray400) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + + Text(record.createdAt.yearMonthDayDot) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.beige600, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + @ViewBuilder + func emptyState() -> some View { + PickeEmptyStateView(message: "아직 배틀 기록이 없어요") + } +} diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift index e5a1fb73..5309de4a 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift @@ -107,9 +107,20 @@ extension ProfileCoordinator { case .routeAction(_, action: .web(.backToRoot)): return .send(.view(.backAction)) - case .routeAction(_, action: .profile(.delegate(.menuSelected))): + // 메뉴 선택 — 내 배틀 기록만 화면 진입 (나머지는 추후). + case let .routeAction(_, action: .profile(.delegate(.menuSelected(item)))): + switch item { + case .battleHistory: + state.routes.push(.battleRecord(.init())) + case .contentActivity, .noticeEvent: + break + } return .none + // 내 배틀 기록 백탭 → 뒤로. + case .routeAction(_, action: .battleRecord(.delegate(.dismiss))): + return .send(.view(.backAction)) + default: return .none } @@ -137,6 +148,7 @@ extension ProfileCoordinator { case profile(ProfileFeature) case pointHistory(PointHistoryFeature) case settings(SettingsFeature) + case battleRecord(BattleRecordFeature) case web(WebReducer) } } diff --git a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift index 08b990c3..4f1e862b 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift @@ -27,6 +27,8 @@ public struct ProfileCoordinatorView: View { PointHistoryView(store: pointHistoryStore) case let .settings(settingsStore): SettingsView(store: settingsStore) + case let .battleRecord(battleRecordStore): + BattleRecordView(store: battleRecordStore) case let .web(webStore): WebView(store: webStore) } diff --git a/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift b/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift index 7178561b..c8ac3c45 100644 --- a/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift +++ b/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift @@ -123,16 +123,6 @@ private extension PointHistoryView { @ViewBuilder func emptyState() -> some View { - VStack(spacing: 8) { - Spacer() - Image(systemName: "tray") - .font(.system(size: 36, weight: .regular)) - .foregroundStyle(.gray300) - Text("포인트 내역이 없습니다") - .pretendardFont(family: .Medium, size: 14) - .foregroundStyle(.gray300) - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + PickeEmptyStateView(message: "포인트 내역이 없어요") } } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Empty/PickeEmptyStateView.swift b/Projects/Shared/DesignSystem/Sources/UI/Empty/PickeEmptyStateView.swift new file mode 100644 index 00000000..1699ba19 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Empty/PickeEmptyStateView.swift @@ -0,0 +1,39 @@ +// +// PickeEmptyStateView.swift +// DesignSystem +// +// 공통 빈 상태 뷰 — noDataLogo + 안내 문구. (배틀 모듈 emptyState 패턴 공통화) +// + +import SwiftUI + +public struct PickeEmptyStateView: View { + private let message: String + private let imageAsset: ImageAsset + private let imageSize: CGSize + + public init( + message: String, + imageAsset: ImageAsset = .noDataLogo, + imageSize: CGSize = CGSize(width: 135, height: 90) + ) { + self.message = message + self.imageAsset = imageAsset + self.imageSize = imageSize + } + + public var body: some View { + VStack(spacing: 8) { + Image(asset: imageAsset) + .resizable() + .scaledToFit() + .frame(width: imageSize.width, height: imageSize.height) + + Text(message) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.beige300) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} From 3e4a57a017d0b5f6219760e4df638981f30b7f51 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 01:31:15 +0900 Subject: [PATCH 07/39] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=ED=99=9C=EB=8F=99(=EB=8C=93=EA=B8=80/=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94)=20+=20=EA=B3=B5=EC=A7=80=EC=82=AC=ED=95=AD?= =?UTF-8?q?=C2=B7=EC=9D=B4=EB=B2=A4=ED=8A=B8=20+=20=EC=8A=A4=EC=BC=88?= =?UTF-8?q?=EB=A0=88=ED=86=A4=20=EA=B3=B5=ED=86=B5=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /me/content-activities 연동 (Clean Architecture 전 레이어) - 내 콘텐츠 활동: 내 댓글/좋아요 탭 전환 + 무한 스크롤 + 스켈레톤, 빈 상태 - 공지사항·이벤트: 탭(공지사항/이벤트), API 미구현으로 빈 콘텐츠 - SkeletonBlock(DesignSystem) 공통 컴포넌트로 추출 — 3개 스켈레톤 중복 제거 - Utill: Date.relativeKoreanString(상대시간) 추가 - 마이페이지 '내 콘텐츠 활동' / '공지방·이벤트' 메뉴 → 화면 push #11 #14 --- .../Data/API/Sources/Profile/ProfileAPI.swift | 3 + .../Profile/DTO/ContentActivityDataDTO.swift | 36 ++++ .../Mapper/ContentActivityDataDTO+.swift | 58 ++++++ .../Profile/ProfileRepositoryImpl.swift | 24 +++ .../ContentActivitiesQueryRequest.swift | 31 +++ .../Sources/Profile/ProfileService.swift | 8 +- .../DefaultProfileRepositoryImpl.swift | 8 + .../Sources/Profile/ProfileInterface.swift | 5 + .../ContentActivity/ContentActivity.swift | 56 +++++ .../ContentActivityAuthor.swift | 25 +++ .../ContentActivity/ContentActivityPage.swift | 24 +++ .../ContentActivity/ContentActivityType.swift | 29 +++ .../Sources/Profile/Notice/NoticeTab.swift | 23 +++ .../Sources/Profile/ProfileUseCase.swift | 12 ++ .../BattleRecordSkeletonView.swift | 36 +--- .../Reducer/ContentActivityFeature.swift | 179 ++++++++++++++++ .../ContentActivitySkeletonView.swift | 59 ++++++ .../View/ContentActivityView.swift | 192 ++++++++++++++++++ .../Reducer/ProfileCoordinator.swift | 18 +- .../View/ProfileCoordinatorView.swift | 4 + .../View/Components/ProfileSkeletonView.swift | 34 +--- .../Notice/Reducer/NoticeFeature.swift | 81 ++++++++ .../Sources/Notice/View/NoticeView.swift | 78 +++++++ .../Components/PointHistorySkeletonView.swift | 34 +--- .../Sources/UI/Skeleton/SkeletonBlock.swift | 65 ++++++ .../Utill/Sources/Extension/Date+.swift | 14 ++ 26 files changed, 1032 insertions(+), 104 deletions(-) create mode 100644 Projects/Data/Model/Sources/Profile/DTO/ContentActivityDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Profile/Mapper/ContentActivityDataDTO+.swift create mode 100644 Projects/Data/Service/Sources/Profile/ContentActivitiesQueryRequest.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivity.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityAuthor.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityPage.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityType.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/Notice/NoticeTab.swift rename Projects/Presentation/Profile/Sources/BattleRecord/View/{ => Components}/BattleRecordSkeletonView.swift (52%) create mode 100644 Projects/Presentation/Profile/Sources/ContentActivity/Reducer/ContentActivityFeature.swift create mode 100644 Projects/Presentation/Profile/Sources/ContentActivity/View/Components/ContentActivitySkeletonView.swift create mode 100644 Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift create mode 100644 Projects/Presentation/Profile/Sources/Notice/Reducer/NoticeFeature.swift create mode 100644 Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift create mode 100644 Projects/Shared/DesignSystem/Sources/UI/Skeleton/SkeletonBlock.swift diff --git a/Projects/Data/API/Sources/Profile/ProfileAPI.swift b/Projects/Data/API/Sources/Profile/ProfileAPI.swift index a151b6ab..b93cf6b0 100644 --- a/Projects/Data/API/Sources/Profile/ProfileAPI.swift +++ b/Projects/Data/API/Sources/Profile/ProfileAPI.swift @@ -9,6 +9,7 @@ public enum ProfileAPI { case mypage case creditsHistory case battleRecords + case contentActivities public var description: String { switch self { @@ -18,6 +19,8 @@ public enum ProfileAPI { return "credits/history" case .battleRecords: return "battle-records" + case .contentActivities: + return "content-activities" } } } diff --git a/Projects/Data/Model/Sources/Profile/DTO/ContentActivityDataDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/ContentActivityDataDTO.swift new file mode 100644 index 00000000..f868d9d9 --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/DTO/ContentActivityDataDTO.swift @@ -0,0 +1,36 @@ +// +// ContentActivityDataDTO.swift +// Model +// +// `GET /api/v1/me/content-activities` 응답 DTO. +// + +import Foundation + +public struct ContentActivityDataDTO: Decodable { + public let items: [ContentActivityItemDTO]? + public let nextOffset: Int? + public let hasNext: Bool? +} + +public struct ContentActivityItemDTO: Decodable { + public let activityId: String? + public let activityType: String? + public let perspectiveId: String? + public let battleId: String? + public let battleTitle: String? + public let author: ContentActivityAuthorDTO? + public let voteSide: String? + public let content: String? + public let likeCount: Int? + public let createdAt: String? +} + +public struct ContentActivityAuthorDTO: Decodable { + public let userTag: String? + public let nickname: String? + public let characterType: String? + public let characterImageUrl: String? +} + +public typealias ContentActivityResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Profile/Mapper/ContentActivityDataDTO+.swift b/Projects/Data/Model/Sources/Profile/Mapper/ContentActivityDataDTO+.swift new file mode 100644 index 00000000..89afcb5d --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/Mapper/ContentActivityDataDTO+.swift @@ -0,0 +1,58 @@ +// +// ContentActivityDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension ContentActivityDataDTO { + func toDomain() -> ContentActivityPage { + ContentActivityPage( + items: (items ?? []).map { $0.toDomain() }, + nextOffset: nextOffset ?? 0, + hasNext: hasNext ?? false + ) + } +} + +public extension ContentActivityItemDTO { + func toDomain() -> ContentActivity { + ContentActivity( + activityId: activityId ?? "", + activityType: ContentActivityType(rawValue: activityType ?? ""), + perspectiveId: perspectiveId ?? "", + battleId: battleId ?? "", + battleTitle: battleTitle ?? "", + author: author?.toDomain() ?? ContentActivityAuthor( + userTag: "", + nickname: "", + characterType: "", + characterImageURL: "" + ), + voteSide: BattleVoteSide(rawValue: voteSide ?? ""), + content: content ?? "", + likeCount: likeCount ?? 0, + createdAt: createdAt.flatMap(Self.parseISO8601) + ) + } + + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} + +public extension ContentActivityAuthorDTO { + func toDomain() -> ContentActivityAuthor { + ContentActivityAuthor( + userTag: userTag ?? "", + nickname: nickname ?? "", + characterType: characterType ?? "", + characterImageURL: characterImageUrl ?? "" + ) + } +} diff --git a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift index 816bef56..fb3311ec 100644 --- a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift @@ -76,4 +76,28 @@ public final class ProfileRepositoryImpl: ProfileInterface, @unchecked Sendable return data.toDomain() } + + public func fetchContentActivities( + offset: Int, + size: Int, + activityType: ContentActivityType? + ) async throws -> ContentActivityPage { + let dto: ContentActivityResponseDTO = try await provider.request( + .contentActivities( + query: ContentActivitiesQueryRequest( + offset: offset, + size: size, + activityType: activityType.flatMap(\.rawValue) + ) + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "콘텐츠 활동 응답이 비어 있습니다" + Log.error("[ProfileRepositoryImpl] empty contentActivities payload: \(message)") + throw ProfileError.backendError(message) + } + + return data.toDomain() + } } diff --git a/Projects/Data/Service/Sources/Profile/ContentActivitiesQueryRequest.swift b/Projects/Data/Service/Sources/Profile/ContentActivitiesQueryRequest.swift new file mode 100644 index 00000000..a0bf4b4f --- /dev/null +++ b/Projects/Data/Service/Sources/Profile/ContentActivitiesQueryRequest.swift @@ -0,0 +1,31 @@ +// +// ContentActivitiesQueryRequest.swift +// Service +// +// GET /api/v1/me/content-activities 쿼리 파라미터. +// + +import Foundation + +public struct ContentActivitiesQueryRequest: Encodable { + public let offset: Int? + public let size: Int? + /// 활동 유형 필터 (COMMENT / LIKE). nil 이면 전체. + public let activityType: String? + + enum CodingKeys: String, CodingKey { + case offset + case size + case activityType = "activity_type" + } + + public init( + offset: Int? = nil, + size: Int? = nil, + activityType: String? = nil + ) { + self.offset = offset + self.size = size + self.activityType = activityType + } +} diff --git a/Projects/Data/Service/Sources/Profile/ProfileService.swift b/Projects/Data/Service/Sources/Profile/ProfileService.swift index 7e55390f..dc9a9169 100644 --- a/Projects/Data/Service/Sources/Profile/ProfileService.swift +++ b/Projects/Data/Service/Sources/Profile/ProfileService.swift @@ -14,6 +14,7 @@ public enum ProfileService { case mypage case creditsHistory(query: CreditHistoryQueryRequest) case battleRecords(query: BattleRecordsQueryRequest) + case contentActivities(query: ContentActivitiesQueryRequest) } extension ProfileService: BaseTargetType { @@ -29,6 +30,8 @@ extension ProfileService: BaseTargetType { return ProfileAPI.creditsHistory.description case .battleRecords: return ProfileAPI.battleRecords.description + case .contentActivities: + return ProfileAPI.contentActivities.description } } @@ -36,7 +39,7 @@ extension ProfileService: BaseTargetType { public var method: Moya.Method { switch self { - case .mypage, .creditsHistory, .battleRecords: + case .mypage, .creditsHistory, .battleRecords, .contentActivities: return .get } } @@ -51,6 +54,9 @@ extension ProfileService: BaseTargetType { case let .battleRecords(query): guard let dict = query.toDictionary else { return nil } return dict.isEmpty ? nil : dict + case let .contentActivities(query): + guard let dict = query.toDictionary else { return nil } + return dict.isEmpty ? nil : dict } } diff --git a/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift index e6a18082..80acad2c 100644 --- a/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift @@ -48,4 +48,12 @@ public struct DefaultProfileRepositoryImpl: ProfileInterface { ) async throws -> BattleRecordPage { BattleRecordPage(items: [], nextOffset: 0, hasNext: false) } + + public func fetchContentActivities( + offset _: Int, + size _: Int, + activityType _: ContentActivityType? + ) async throws -> ContentActivityPage { + ContentActivityPage(items: [], nextOffset: 0, hasNext: false) + } } diff --git a/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift index 3cf2860d..34eca088 100644 --- a/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift @@ -18,6 +18,11 @@ public protocol ProfileInterface: Sendable { size: Int, voteSide: BattleVoteSide? ) async throws -> BattleRecordPage + func fetchContentActivities( + offset: Int, + size: Int, + activityType: ContentActivityType? + ) async throws -> ContentActivityPage } public struct ProfileRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivity.swift b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivity.swift new file mode 100644 index 00000000..1dc7ec24 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivity.swift @@ -0,0 +1,56 @@ +// +// ContentActivity.swift +// Entity +// +// `GET /api/v1/me/content-activities` 항목 (내가 단/좋아요한 댓글). +// + +import Foundation + +public struct ContentActivity: Equatable, Identifiable { + public let activityId: String + public let activityType: ContentActivityType + public let perspectiveId: String + public let battleId: String + public let battleTitle: String + public let author: ContentActivityAuthor + public let voteSide: BattleVoteSide + public let content: String + public let likeCount: Int + public let createdAt: Date? + + public var id: String { activityId } + + /// 의견 칩 텍스트 (찬성의견 / 반대의견). + public var stanceText: String { + switch voteSide { + case .pro: "찬성의견" + case .con: "반대의견" + case .unknown: "" + } + } + + public init( + activityId: String, + activityType: ContentActivityType, + perspectiveId: String, + battleId: String, + battleTitle: String, + author: ContentActivityAuthor, + voteSide: BattleVoteSide, + content: String, + likeCount: Int, + createdAt: Date? + ) { + self.activityId = activityId + self.activityType = activityType + self.perspectiveId = perspectiveId + self.battleId = battleId + self.battleTitle = battleTitle + self.author = author + self.voteSide = voteSide + self.content = content + self.likeCount = likeCount + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityAuthor.swift b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityAuthor.swift new file mode 100644 index 00000000..a2e62387 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityAuthor.swift @@ -0,0 +1,25 @@ +// +// ContentActivityAuthor.swift +// Entity +// + +import Foundation + +public struct ContentActivityAuthor: Equatable { + public let userTag: String + public let nickname: String + public let characterType: String + public let characterImageURL: String + + public init( + userTag: String, + nickname: String, + characterType: String, + characterImageURL: String + ) { + self.userTag = userTag + self.nickname = nickname + self.characterType = characterType + self.characterImageURL = characterImageURL + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityPage.swift b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityPage.swift new file mode 100644 index 00000000..24194b70 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityPage.swift @@ -0,0 +1,24 @@ +// +// ContentActivityPage.swift +// Entity +// +// `GET /api/v1/me/content-activities` 응답 (offset 페이지네이션). +// + +import Foundation + +public struct ContentActivityPage: Equatable { + public let items: [ContentActivity] + public let nextOffset: Int + public let hasNext: Bool + + public init( + items: [ContentActivity], + nextOffset: Int, + hasNext: Bool + ) { + self.items = items + self.nextOffset = nextOffset + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityType.swift b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityType.swift new file mode 100644 index 00000000..77f33556 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityType.swift @@ -0,0 +1,29 @@ +// +// ContentActivityType.swift +// Entity +// + +import Foundation + +/// 콘텐츠 활동 유형 (탭). +public enum ContentActivityType: String, Equatable, CaseIterable, Identifiable { + case comment = "COMMENT" + case like = "LIKE" + + public var id: String { rawValue } + + /// 탭 표시명. + public var title: String { + switch self { + case .comment: "내 댓글" + case .like: "좋아요" + } + } + + public init(rawValue: String) { + switch rawValue.uppercased() { + case "LIKE": self = .like + default: self = .comment + } + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Notice/NoticeTab.swift b/Projects/Domain/Entity/Sources/Profile/Notice/NoticeTab.swift new file mode 100644 index 00000000..a5726e59 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Notice/NoticeTab.swift @@ -0,0 +1,23 @@ +// +// NoticeTab.swift +// Entity +// +// 공지사항 · 이벤트 탭. +// + +import Foundation + +public enum NoticeTab: String, CaseIterable, Identifiable, Equatable { + case notice = "NOTICE" + case event = "EVENT" + + public var id: String { rawValue } + + /// 탭 표시명. + public var title: String { + switch self { + case .notice: "공지사항" + case .event: "이벤트" + } + } +} diff --git a/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift index b0b7e8e2..468612ad 100644 --- a/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift +++ b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift @@ -37,6 +37,18 @@ public struct ProfileUseCaseImpl: ProfileInterface { voteSide: voteSide ) } + + public func fetchContentActivities( + offset: Int, + size: Int, + activityType: ContentActivityType? + ) async throws -> ContentActivityPage { + return try await profileRepository.fetchContentActivities( + offset: offset, + size: size, + activityType: activityType + ) + } } extension ProfileUseCaseImpl: DependencyKey { diff --git a/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordSkeletonView.swift b/Projects/Presentation/Profile/Sources/BattleRecord/View/Components/BattleRecordSkeletonView.swift similarity index 52% rename from Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordSkeletonView.swift rename to Projects/Presentation/Profile/Sources/BattleRecord/View/Components/BattleRecordSkeletonView.swift index 0eae63e0..9e34177f 100644 --- a/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordSkeletonView.swift +++ b/Projects/Presentation/Profile/Sources/BattleRecord/View/Components/BattleRecordSkeletonView.swift @@ -2,7 +2,7 @@ // BattleRecordSkeletonView.swift // Profile // -// 내 배틀 기록 로딩 스켈레톤 — 라이트(beige) shimmer. +// 내 배틀 기록 로딩 스켈레톤 — 공통 SkeletonBlock(light) 사용. // import SwiftUI @@ -43,41 +43,9 @@ struct BattleRecordSkeletonView: View { maxWidth: Bool = false, height: CGFloat ) -> some View { - LightSkeletonBlock(cornerRadius: 4) + SkeletonBlock(cornerRadius: 4, tone: .light) .frame(width: width) .frame(maxWidth: maxWidth ? .infinity : nil) .frame(height: height) } } - -/// 라이트 배경용 skeleton 블록. -private struct LightSkeletonBlock: View { - let cornerRadius: CGFloat - - @State private var phase: CGFloat = -1 - - private let baseColor = Color.black.opacity(0.06) - private let shimmerColor = Color.white.opacity(0.55) - - var body: some View { - RoundedRectangle(cornerRadius: cornerRadius) - .fill(baseColor) - .overlay { - LinearGradient( - stops: [ - .init(color: shimmerColor.opacity(0), location: 0), - .init(color: shimmerColor, location: 0.5), - .init(color: shimmerColor.opacity(0), location: 1), - ], - startPoint: UnitPoint(x: phase, y: 0.5), - endPoint: UnitPoint(x: phase + 1, y: 0.5) - ) - } - .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - .onAppear { - withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { - phase = 2 - } - } - } -} diff --git a/Projects/Presentation/Profile/Sources/ContentActivity/Reducer/ContentActivityFeature.swift b/Projects/Presentation/Profile/Sources/ContentActivity/Reducer/ContentActivityFeature.swift new file mode 100644 index 00000000..0c19b094 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/ContentActivity/Reducer/ContentActivityFeature.swift @@ -0,0 +1,179 @@ +// +// ContentActivityFeature.swift +// Profile +// +// 내 콘텐츠 활동 — picke.pen `내 콘텐츠활동_댓글/좋아요`. +// 내 댓글 / 좋아요 탭, GET /api/v1/me/content-activities (offset 페이지네이션). +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro +import UseCase + +@Reducer +public struct ContentActivityFeature { + public init() {} + + static let pageSize = 20 + + @ObservableState + public struct State: Equatable { + public var selectedTab: ContentActivityType = .comment + public var isLoading: Bool = false + public var isLoadingMore: Bool = false + public var items: [ContentActivity] = [] + public var nextOffset: Int = 0 + public var hasNext: Bool = false + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backTapped + case tabSelected(ContentActivityType) + case reachedBottom + } + + public enum AsyncAction: Equatable { + case fetch(reset: Bool) + } + + public enum InnerAction: Equatable { + case activitiesResponse(Result, reset: Bool) + } + + public enum DelegateAction: Equatable { + case dismiss + } + + nonisolated enum CancelID: Hashable { + case fetch + } + + @Dependency(\.profileUseCase) private var profileUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension ContentActivityFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard state.items.isEmpty else { return .none } + return .send(.async(.fetch(reset: true))) + + case .backTapped: + return .send(.delegate(.dismiss)) + + case let .tabSelected(tab): + guard tab != state.selectedTab else { return .none } + state.selectedTab = tab + state.items = [] + state.nextOffset = 0 + state.hasNext = false + return .send(.async(.fetch(reset: true))) + + case .reachedBottom: + guard state.hasNext, !state.isLoadingMore, !state.isLoading else { return .none } + return .send(.async(.fetch(reset: false))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case let .fetch(reset): + if reset { + state.isLoading = true + } else { + state.isLoadingMore = true + } + let offset = reset ? 0 : state.nextOffset + let activityType = state.selectedTab + return .run { [useCase = profileUseCase] send in + let result = await Result { + try await useCase.fetchContentActivities( + offset: offset, + size: Self.pageSize, + activityType: activityType + ) + } + .mapError(ProfileError.from) + return await send(.inner(.activitiesResponse(result, reset: reset))) + } + .cancellable(id: CancelID.fetch, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .activitiesResponse(result, reset): + state.isLoading = false + state.isLoadingMore = false + switch result { + case let .success(page): + if reset { + state.items = page.items + } else { + state.items.append(contentsOf: page.items) + } + state.nextOffset = page.nextOffset + state.hasNext = page.hasNext + case let .failure(error): + Log.error("[ContentActivityFeature] fetchContentActivities failed: \(error.localizedDescription)") + } + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/ContentActivity/View/Components/ContentActivitySkeletonView.swift b/Projects/Presentation/Profile/Sources/ContentActivity/View/Components/ContentActivitySkeletonView.swift new file mode 100644 index 00000000..66edef3b --- /dev/null +++ b/Projects/Presentation/Profile/Sources/ContentActivity/View/Components/ContentActivitySkeletonView.swift @@ -0,0 +1,59 @@ +// +// ContentActivitySkeletonView.swift +// Profile +// +// 내 콘텐츠 활동 로딩 스켈레톤 — 공통 SkeletonBlock(light). +// + +import SwiftUI + +import DesignSystem + +struct ContentActivitySkeletonView: View { + var body: some View { + ScrollView { + VStack(spacing: 12) { + ForEach(0 ..< 5, id: \.self) { _ in + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + SkeletonBlock(cornerRadius: 18, tone: .light) + .frame(width: 36, height: 36) + VStack(alignment: .leading, spacing: 4) { + block(width: 120, height: 14) + block(width: 50, height: 10) + } + Spacer(minLength: 0) + } + block(width: nil, maxWidth: true, height: 36) + HStack { + Spacer() + block(width: 40, height: 12) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.beige600, lineWidth: 1) + ) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + } + + @ViewBuilder + private func block( + width: CGFloat?, + maxWidth: Bool = false, + height: CGFloat + ) -> some View { + SkeletonBlock(cornerRadius: 4, tone: .light) + .frame(width: width) + .frame(maxWidth: maxWidth ? .infinity : nil) + .frame(height: height) + } +} diff --git a/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift b/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift new file mode 100644 index 00000000..e11f2e8d --- /dev/null +++ b/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift @@ -0,0 +1,192 @@ +// +// ContentActivityView.swift +// Profile +// +// 내 콘텐츠 활동 UI — picke.pen `내 콘텐츠활동_댓글/좋아요`. +// App Bar + 탭바(내 댓글/좋아요) + 카드 리스트 + 무한 스크롤. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity +import Kingfisher +import Utill + +@ViewAction(for: ContentActivityFeature.self) +public struct ContentActivityView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "내 콘텐츠 활동") + .foregroundStyle(.gray500) + + tabBar() + + if store.isLoading { + ContentActivitySkeletonView() + } else { + content() + } + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + } +} + +private extension ContentActivityView { + // MARK: 탭바 (내 댓글 / 좋아요) + + @ViewBuilder + func tabBar() -> some View { + HStack(spacing: 0) { + ForEach(ContentActivityType.allCases) { tab in + tabButton(tab) + } + } + .overlay(alignment: .bottom) { + Rectangle().fill(.neutral200).frame(height: 1) + } + } + + @ViewBuilder + func tabButton(_ tab: ContentActivityType) -> some View { + let isSelected = store.selectedTab == tab + Button { + send(.tabSelected(tab)) + } label: { + Text(tab.title) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(isSelected ? .primary500 : .gray300) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .contentShape(Rectangle()) + .overlay(alignment: .bottom) { + Rectangle() + .fill(isSelected ? Color.primary500 : Color.clear) + .frame(height: 3) + } + } + .buttonStyle(.plain) + } + + // MARK: 리스트 + + @ViewBuilder + func content() -> some View { + if store.items.isEmpty { + PickeEmptyStateView(message: emptyMessage) + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(store.items) { item in + activityCard(item) + .onAppear { + if item.id == store.items.last?.id { + send(.reachedBottom) + } + } + } + + if store.isLoadingMore { + ProgressView().padding(.vertical, 8) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + } + } + + var emptyMessage: String { + switch store.selectedTab { + case .comment: "작성한 댓글이 없어요" + case .like: "좋아요한 댓글이 없어요" + } + } + + @ViewBuilder + func activityCard(_ item: ContentActivity) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + avatar(item.author) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(item.author.nickname) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.gray500) + .lineLimit(1) + + if !item.stanceText.isEmpty { + Text(item.stanceText) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.primary500) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + } + } + + Text(item.createdAt.relativeKoreanString) + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.gray300) + } + + Spacer(minLength: 0) + } + + Text(item.content) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.gray400) + .lineLimit(4) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 4) { + Spacer(minLength: 0) + Image(systemName: "heart") + .font(.system(size: 13)) + .foregroundStyle(.gray300) + Text("\(item.likeCount)") + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.beige600, lineWidth: 1) + ) + } + + @ViewBuilder + func avatar(_ author: ContentActivityAuthor) -> some View { + if let url = URL(string: author.characterImageURL), !author.characterImageURL.isEmpty { + KFImage(url) + .resizable() + .scaledToFill() + .frame(width: 36, height: 36) + .clipShape(Circle()) + } else { + ZStack { + Circle().fill(.beige600) + Image(systemName: "cat.fill") + .font(.system(size: 16)) + .foregroundStyle(.gray300) + } + .frame(width: 36, height: 36) + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift index 5309de4a..177eed25 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift @@ -107,13 +107,15 @@ extension ProfileCoordinator { case .routeAction(_, action: .web(.backToRoot)): return .send(.view(.backAction)) - // 메뉴 선택 — 내 배틀 기록만 화면 진입 (나머지는 추후). + // 메뉴 선택 — 화면 진입 분기. case let .routeAction(_, action: .profile(.delegate(.menuSelected(item)))): switch item { case .battleHistory: state.routes.push(.battleRecord(.init())) - case .contentActivity, .noticeEvent: - break + case .contentActivity: + state.routes.push(.contentActivity(.init())) + case .noticeEvent: + state.routes.push(.notice(.init())) } return .none @@ -121,6 +123,14 @@ extension ProfileCoordinator { case .routeAction(_, action: .battleRecord(.delegate(.dismiss))): return .send(.view(.backAction)) + // 내 콘텐츠 활동 백탭 → 뒤로. + case .routeAction(_, action: .contentActivity(.delegate(.dismiss))): + return .send(.view(.backAction)) + + // 공지사항·이벤트 백탭 → 뒤로. + case .routeAction(_, action: .notice(.delegate(.dismiss))): + return .send(.view(.backAction)) + default: return .none } @@ -149,6 +159,8 @@ extension ProfileCoordinator { case pointHistory(PointHistoryFeature) case settings(SettingsFeature) case battleRecord(BattleRecordFeature) + case contentActivity(ContentActivityFeature) + case notice(NoticeFeature) case web(WebReducer) } } diff --git a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift index 4f1e862b..415f723a 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift @@ -29,6 +29,10 @@ public struct ProfileCoordinatorView: View { SettingsView(store: settingsStore) case let .battleRecord(battleRecordStore): BattleRecordView(store: battleRecordStore) + case let .contentActivity(contentActivityStore): + ContentActivityView(store: contentActivityStore) + case let .notice(noticeStore): + NoticeView(store: noticeStore) case let .web(webStore): WebView(store: webStore) } diff --git a/Projects/Presentation/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift b/Projects/Presentation/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift index d8c0ba06..0e475847 100644 --- a/Projects/Presentation/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift +++ b/Projects/Presentation/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift @@ -55,41 +55,9 @@ struct ProfileSkeletonView: View { height: CGFloat, radius: CGFloat = 4 ) -> some View { - LightSkeletonBlock(cornerRadius: radius) + SkeletonBlock(cornerRadius: radius, tone: .light) .frame(width: width) .frame(maxWidth: maxWidth ? .infinity : nil) .frame(height: height) } } - -/// 라이트 배경용 skeleton 블록 (옅은 회색 base + 흰색 shimmer). -private struct LightSkeletonBlock: View { - let cornerRadius: CGFloat - - @State private var phase: CGFloat = -1 - - private let baseColor = Color.black.opacity(0.06) - private let shimmerColor = Color.white.opacity(0.55) - - var body: some View { - RoundedRectangle(cornerRadius: cornerRadius) - .fill(baseColor) - .overlay { - LinearGradient( - stops: [ - .init(color: shimmerColor.opacity(0), location: 0), - .init(color: shimmerColor, location: 0.5), - .init(color: shimmerColor.opacity(0), location: 1), - ], - startPoint: UnitPoint(x: phase, y: 0.5), - endPoint: UnitPoint(x: phase + 1, y: 0.5) - ) - } - .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - .onAppear { - withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { - phase = 2 - } - } - } -} diff --git a/Projects/Presentation/Profile/Sources/Notice/Reducer/NoticeFeature.swift b/Projects/Presentation/Profile/Sources/Notice/Reducer/NoticeFeature.swift new file mode 100644 index 00000000..9f48dc4c --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Notice/Reducer/NoticeFeature.swift @@ -0,0 +1,81 @@ +// +// NoticeFeature.swift +// Profile +// +// 공지사항 · 이벤트 — 탭 전환(공지사항/이벤트). API 미구현으로 빈 콘텐츠. +// + +import Foundation + +import ComposableArchitecture +import Entity + +@Reducer +public struct NoticeFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var selectedTab: NoticeTab = .notice + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case backTapped + case tabSelected(NoticeTab) + } + + public enum DelegateAction: Equatable { + case dismiss + } + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension NoticeFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backTapped: + return .send(.delegate(.dismiss)) + + case let .tabSelected(tab): + state.selectedTab = tab + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift b/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift new file mode 100644 index 00000000..aa61c05d --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift @@ -0,0 +1,78 @@ +// +// NoticeView.swift +// Profile +// +// 공지사항 · 이벤트 UI — App Bar + 탭바(공지사항/이벤트) + 빈 콘텐츠. +// API 미구현 — 현재는 항상 빈 상태. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +@ViewAction(for: NoticeFeature.self) +public struct NoticeView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "공지사항 · 이벤트") + .foregroundStyle(.gray500) + + tabBar() + + PickeEmptyStateView(message: emptyMessage) + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + } +} + +private extension NoticeView { + @ViewBuilder + func tabBar() -> some View { + HStack(spacing: 0) { + ForEach(NoticeTab.allCases) { tab in + tabButton(tab) + } + } + .overlay(alignment: .bottom) { + Rectangle().fill(.neutral200).frame(height: 1) + } + } + + @ViewBuilder + func tabButton(_ tab: NoticeTab) -> some View { + let isSelected = store.selectedTab == tab + Button { + send(.tabSelected(tab)) + } label: { + Text(tab.title) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(isSelected ? .primary500 : .gray300) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .contentShape(Rectangle()) + .overlay(alignment: .bottom) { + Rectangle() + .fill(isSelected ? Color.primary500 : Color.clear) + .frame(height: 3) + } + } + .buttonStyle(.plain) + } + + var emptyMessage: String { + switch store.selectedTab { + case .notice: "등록된 공지사항이 없어요" + case .event: "진행 중인 이벤트가 없어요" + } + } +} diff --git a/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift b/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift index 6bb1fbd7..d9666e15 100644 --- a/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift +++ b/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift @@ -42,39 +42,7 @@ struct PointHistorySkeletonView: View { @ViewBuilder private func block(width: CGFloat, height: CGFloat) -> some View { - LightSkeletonBlock(cornerRadius: 4) + SkeletonBlock(cornerRadius: 4, tone: .light) .frame(width: width, height: height) } } - -/// 라이트 배경용 skeleton 블록 (옅은 회색 base + 흰색 shimmer). -private struct LightSkeletonBlock: View { - let cornerRadius: CGFloat - - @State private var phase: CGFloat = -1 - - private let baseColor = Color.black.opacity(0.06) - private let shimmerColor = Color.white.opacity(0.55) - - var body: some View { - RoundedRectangle(cornerRadius: cornerRadius) - .fill(baseColor) - .overlay { - LinearGradient( - stops: [ - .init(color: shimmerColor.opacity(0), location: 0), - .init(color: shimmerColor, location: 0.5), - .init(color: shimmerColor.opacity(0), location: 1), - ], - startPoint: UnitPoint(x: phase, y: 0.5), - endPoint: UnitPoint(x: phase + 1, y: 0.5) - ) - } - .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - .onAppear { - withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { - phase = 2 - } - } - } -} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Skeleton/SkeletonBlock.swift b/Projects/Shared/DesignSystem/Sources/UI/Skeleton/SkeletonBlock.swift new file mode 100644 index 00000000..bf9e1485 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Skeleton/SkeletonBlock.swift @@ -0,0 +1,65 @@ +// +// SkeletonBlock.swift +// DesignSystem +// +// 공통 스켈레톤 블록 — base + shimmer 그라데이션. (라이트/다크 배경 모두 지원) +// + +import SwiftUI + +public struct SkeletonBlock: View { + /// 배경 톤 — 라이트(beige) / 다크 화면에 맞춰 base·shimmer 색을 고른다. + public enum Tone { + case light + case dark + + var base: Color { + switch self { + case .light: Color.black.opacity(0.06) + case .dark: Color.white.opacity(0.08) + } + } + + var shimmer: Color { + switch self { + case .light: Color.white.opacity(0.55) + case .dark: Color.white.opacity(0.20) + } + } + } + + private let cornerRadius: CGFloat + private let tone: Tone + + @State private var phase: CGFloat = -1 + + public init( + cornerRadius: CGFloat = 4, + tone: Tone = .light + ) { + self.cornerRadius = cornerRadius + self.tone = tone + } + + public var body: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(tone.base) + .overlay { + LinearGradient( + stops: [ + .init(color: tone.shimmer.opacity(0), location: 0), + .init(color: tone.shimmer, location: 0.5), + .init(color: tone.shimmer.opacity(0), location: 1), + ], + startPoint: UnitPoint(x: phase, y: 0.5), + endPoint: UnitPoint(x: phase + 1, y: 0.5) + ) + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .onAppear { + withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { + phase = 2 + } + } + } +} diff --git a/Projects/Shared/Utill/Sources/Extension/Date+.swift b/Projects/Shared/Utill/Sources/Extension/Date+.swift index 497cbd5a..841af62c 100644 --- a/Projects/Shared/Utill/Sources/Extension/Date+.swift +++ b/Projects/Shared/Utill/Sources/Extension/Date+.swift @@ -20,6 +20,15 @@ public extension Date { var yearMonthDayDot: String { toString(format: "yyyy.M.d") } + + /// 한국어 상대 시간 (방금 전 / N분 전 / N시간 전 / N일 전). + var relativeKoreanString: String { + let interval = Date().timeIntervalSince(self) + if interval < 60 { return "방금 전" } + if interval < 3600 { return "\(Int(interval / 60))분 전" } + if interval < 86400 { return "\(Int(interval / 3600))시간 전" } + return "\(Int(interval / 86400))일 전" + } } public extension Date? { @@ -27,4 +36,9 @@ public extension Date? { var yearMonthDayDot: String { self?.yearMonthDayDot ?? "" } + + /// nil 이면 빈 문자열. + var relativeKoreanString: String { + self?.relativeKoreanString ?? "" + } } From 24f842f1e747f8d5c11829980cef1330f76854ef Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 01:41:39 +0900 Subject: [PATCH 08/39] =?UTF-8?q?fix:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=E2=80=94=20=EC=95=84=EB=B0=94=ED=83=80=20?= =?UTF-8?q?=EC=9B=90=20=EB=B0=B0=EA=B2=BD=20=EC=A0=81=EC=9A=A9=20+=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=B9=B4=EB=93=9C=20=ED=8C=A8?= =?UTF-8?q?=EB=94=A9/=EA=B0=84=EA=B2=A9=20=ED=86=B5=EC=9D=BC=20+=20?= =?UTF-8?q?=EC=83=89=EC=83=81=20=EB=8B=A8=EC=B6=95=ED=98=95=20+=20Notice?= =?UTF-8?q?=20=EB=B9=88=20=EB=AC=B8=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로필/콘텐츠활동 아바타: 항상 beige600 원 배경 위에 캐릭터 이미지/기본 아이콘 - 포인트내역 카드 패딩/간격을 배틀기록·콘텐츠활동과 12/12 로 통일 - .background(Color.xxx) → .background(.xxx, in:) 단축형 정리 - 공지/이벤트 빈 문구: 새로운 공지사항/이벤트가 없습니다 #9 #15 --- Projects/Presentation/Profile/Project.swift | 6 ++-- .../View/ContentActivityView.swift | 22 +++++++------ .../Sources/Main/View/ProfileView.swift | 31 +++++++++---------- .../Sources/Notice/View/NoticeView.swift | 4 +-- .../Components/PointHistorySkeletonView.swift | 5 ++- .../PointHistory/View/PointHistoryView.swift | 7 ++--- 6 files changed, 37 insertions(+), 38 deletions(-) diff --git a/Projects/Presentation/Profile/Project.swift b/Projects/Presentation/Profile/Project.swift index 80ac0f8a..1d5039ff 100644 --- a/Projects/Presentation/Profile/Project.swift +++ b/Projects/Presentation/Profile/Project.swift @@ -1,8 +1,8 @@ +import DependencyPackagePlugin +import DependencyPlugin import Foundation import ProjectDescription -import DependencyPlugin import ProjectTemplatePlugin -import DependencyPackagePlugin let project = Project.makeAppModule( name: "Profile", @@ -14,7 +14,7 @@ let project = Project.makeAppModule( .Shared(implements: .Shared), .SPM.composableArchitecture, .SPM.tcaFlow, - .Presentation(implements: .Web) + .Presentation(implements: .Web), ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift b/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift index e11f2e8d..7c51afbc 100644 --- a/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift +++ b/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift @@ -173,20 +173,22 @@ private extension ContentActivityView { @ViewBuilder func avatar(_ author: ContentActivityAuthor) -> some View { - if let url = URL(string: author.characterImageURL), !author.characterImageURL.isEmpty { - KFImage(url) - .resizable() - .scaledToFill() - .frame(width: 36, height: 36) - .clipShape(Circle()) - } else { - ZStack { - Circle().fill(.beige600) + // 디자인(Z5YAW): 항상 beige600 원 배경 위에 캐릭터/기본 아이콘. + ZStack { + Circle().fill(.beige600) + + if !author.characterImageURL.isEmpty, let url = URL(string: author.characterImageURL) { + KFImage(url) + .resizable() + .scaledToFit() + .padding(3) + } else { Image(systemName: "cat.fill") .font(.system(size: 16)) .foregroundStyle(.gray300) } - .frame(width: 36, height: 36) } + .frame(width: 36, height: 36) + .clipShape(Circle()) } } diff --git a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift index b4b5c6f1..5854b725 100644 --- a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift +++ b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift @@ -113,21 +113,23 @@ private extension ProfileView { @ViewBuilder func avatar() -> some View { - if let urlString = store.profileImageURL, let url = URL(string: urlString) { - KFImage(url) - .resizable() - .scaledToFill() - .frame(width: 52, height: 52) - .clipShape(Circle()) - } else { - ZStack { - Circle().fill(.beige600) + // 디자인(oFtBQ): 항상 beige600 원 배경 위에 캐릭터 이미지/기본 아이콘을 올린다. + ZStack { + Circle().fill(.beige600) + + if let urlString = store.profileImageURL, let url = URL(string: urlString) { + KFImage(url) + .resizable() + .scaledToFit() + .padding(4) + } else { Image(systemName: "cat.fill") .font(.system(size: 24)) .foregroundStyle(.gray300) } - .frame(width: 52, height: 52) } + .frame(width: 52, height: 52) + .clipShape(Circle()) } // MARK: 포인트 충전 버튼 @@ -158,14 +160,12 @@ private extension ProfileView { .foregroundStyle(.gray800) .padding(.vertical, 4) .padding(.horizontal, 6) - .background(Color.secondary300) - .clipShape(RoundedRectangle(cornerRadius: 2)) + .background(.secondary300, in: RoundedRectangle(cornerRadius: 2)) } .padding(.vertical, 20) .padding(.horizontal, 16) .frame(maxWidth: .infinity) - .background(Color.primary800) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .background(.primary800, in: RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) } @@ -205,8 +205,7 @@ private extension ProfileView { } .padding(16) .frame(maxWidth: .infinity) - .background(Color.beige400) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .background(.beige400, in: RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(.beige600, lineWidth: 1) diff --git a/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift b/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift index aa61c05d..6c635569 100644 --- a/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift +++ b/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift @@ -71,8 +71,8 @@ private extension NoticeView { var emptyMessage: String { switch store.selectedTab { - case .notice: "등록된 공지사항이 없어요" - case .event: "진행 중인 이벤트가 없어요" + case .notice: "새로운 공지사항이 없습니다" + case .event: "새로운 이벤트가 없습니다" } } } diff --git a/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift b/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift index d9666e15..d4c365d8 100644 --- a/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift +++ b/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift @@ -25,10 +25,9 @@ struct PointHistorySkeletonView: View { block(width: 28, height: 12) } } - .padding(16) + .padding(12) .frame(maxWidth: .infinity) - .background(Color.beige50) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .background(.beige50, in: RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(.beige600, lineWidth: 1) diff --git a/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift b/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift index c8ac3c45..e1fee26f 100644 --- a/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift +++ b/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift @@ -64,7 +64,7 @@ private extension PointHistoryView { emptyState() } else { ScrollView { - LazyVStack(spacing: 16) { + LazyVStack(spacing: 12) { ForEach(store.items) { item in historyRow(item) .onAppear { @@ -111,10 +111,9 @@ private extension PointHistoryView { .foregroundStyle(.gray300) } } - .padding(16) + .padding(12) .frame(maxWidth: .infinity) - .background(Color.beige50) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .background(.beige50, in: RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(.beige600, lineWidth: 1) From 18de7b4a00400ad8a49c7cc28cfb8a1610df3368 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 01:48:17 +0900 Subject: [PATCH 09/39] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95(GET/PATCH)=20+=20=EC=BD=98=ED=85=90=EC=B8=A0=ED=99=9C?= =?UTF-8?q?=EB=8F=99=C2=B7=EA=B3=B5=EC=A7=80=20=ED=83=AD=20=EC=8A=A4?= =?UTF-8?q?=EC=99=80=EC=9D=B4=ED=94=84=20+=20=EC=98=A4=EB=B2=84=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET/PATCH /me/notification-settings 연동 (Clean Architecture 전 레이어) - 알림 설정: 3섹션(기능별/소셜/마케팅) 6토글, 토글 시 낙관적 갱신 + PATCH, 커스텀 토글(32×18) + 스켈레톤 - 설정 '알림설정' 메뉴 → 알림 설정 화면 push - 내 콘텐츠 활동/공지·이벤트: 좌우 DragGesture 로 탭 전환(CommentView UX) - Profile 전체 ScrollView .scrollBounceBehavior(.basedOnSize) 오버스크롤 방지 - Entity 1타입 1파일(NotificationSettings/Key/Section) #10 #11 --- .../Data/API/Sources/Profile/ProfileAPI.swift | 3 + .../DTO/NotificationSettingsDataDTO.swift | 19 +++ .../Mapper/NotificationSettingsDataDTO+.swift | 20 +++ .../Profile/ProfileRepositoryImpl.swift | 35 ++++ .../Profile/NotificationSettingsRequest.swift | 33 ++++ .../Sources/Profile/ProfileService.swift | 15 +- .../DefaultProfileRepositoryImpl.swift | 8 + .../Sources/Profile/ProfileInterface.swift | 2 + .../Notification/NotificationSettingKey.swift | 48 ++++++ .../NotificationSettingSection.swift | 28 +++ .../Notification/NotificationSettings.swift | 59 +++++++ .../Sources/Profile/ProfileUseCase.swift | 8 + .../BattleRecord/View/BattleRecordView.swift | 1 + .../View/ContentActivityView.swift | 30 +++- .../Reducer/ProfileCoordinator.swift | 10 ++ .../View/ProfileCoordinatorView.swift | 2 + .../Sources/Notice/View/NoticeView.swift | 20 +++ .../Reducer/NotificationSettingFeature.swift | 160 ++++++++++++++++++ .../NotificationSettingSkeletonView.swift | 53 ++++++ .../View/NotificationSettingView.swift | 114 +++++++++++++ .../PointHistory/View/PointHistoryView.swift | 1 + 21 files changed, 664 insertions(+), 5 deletions(-) create mode 100644 Projects/Data/Model/Sources/Profile/DTO/NotificationSettingsDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Profile/Mapper/NotificationSettingsDataDTO+.swift create mode 100644 Projects/Data/Service/Sources/Profile/NotificationSettingsRequest.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettingKey.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettingSection.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettings.swift create mode 100644 Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift create mode 100644 Projects/Presentation/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift create mode 100644 Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift diff --git a/Projects/Data/API/Sources/Profile/ProfileAPI.swift b/Projects/Data/API/Sources/Profile/ProfileAPI.swift index b93cf6b0..02f0ae2b 100644 --- a/Projects/Data/API/Sources/Profile/ProfileAPI.swift +++ b/Projects/Data/API/Sources/Profile/ProfileAPI.swift @@ -10,6 +10,7 @@ public enum ProfileAPI { case creditsHistory case battleRecords case contentActivities + case notificationSettings public var description: String { switch self { @@ -21,6 +22,8 @@ public enum ProfileAPI { return "battle-records" case .contentActivities: return "content-activities" + case .notificationSettings: + return "notification-settings" } } } diff --git a/Projects/Data/Model/Sources/Profile/DTO/NotificationSettingsDataDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/NotificationSettingsDataDTO.swift new file mode 100644 index 00000000..b57801da --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/DTO/NotificationSettingsDataDTO.swift @@ -0,0 +1,19 @@ +// +// NotificationSettingsDataDTO.swift +// Model +// +// `GET/PATCH /api/v1/me/notification-settings` 응답 DTO. +// + +import Foundation + +public struct NotificationSettingsDataDTO: Decodable { + public let newBattleEnabled: Bool? + public let battleResultEnabled: Bool? + public let commentReplyEnabled: Bool? + public let newCommentEnabled: Bool? + public let contentLikeEnabled: Bool? + public let marketingEventEnabled: Bool? +} + +public typealias NotificationSettingsResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Profile/Mapper/NotificationSettingsDataDTO+.swift b/Projects/Data/Model/Sources/Profile/Mapper/NotificationSettingsDataDTO+.swift new file mode 100644 index 00000000..189dc23d --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/Mapper/NotificationSettingsDataDTO+.swift @@ -0,0 +1,20 @@ +// +// NotificationSettingsDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension NotificationSettingsDataDTO { + func toDomain() -> NotificationSettings { + NotificationSettings( + newBattleEnabled: newBattleEnabled ?? false, + battleResultEnabled: battleResultEnabled ?? false, + commentReplyEnabled: commentReplyEnabled ?? false, + newCommentEnabled: newCommentEnabled ?? false, + contentLikeEnabled: contentLikeEnabled ?? false, + marketingEventEnabled: marketingEventEnabled ?? false + ) + } +} diff --git a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift index fb3311ec..1d43b2c7 100644 --- a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift @@ -100,4 +100,39 @@ public final class ProfileRepositoryImpl: ProfileInterface, @unchecked Sendable return data.toDomain() } + + public func fetchNotificationSettings() async throws -> NotificationSettings { + let dto: NotificationSettingsResponseDTO = try await provider.request(.notificationSettings) + + guard let data = dto.data else { + let message = dto.error?.message ?? "알림 설정 응답이 비어 있습니다" + Log.error("[ProfileRepositoryImpl] empty notificationSettings payload: \(message)") + throw ProfileError.backendError(message) + } + + return data.toDomain() + } + + public func updateNotificationSettings(_ settings: NotificationSettings) async throws -> NotificationSettings { + let dto: NotificationSettingsResponseDTO = try await provider.request( + .updateNotificationSettings( + body: NotificationSettingsRequest( + newBattleEnabled: settings.newBattleEnabled, + battleResultEnabled: settings.battleResultEnabled, + commentReplyEnabled: settings.commentReplyEnabled, + newCommentEnabled: settings.newCommentEnabled, + contentLikeEnabled: settings.contentLikeEnabled, + marketingEventEnabled: settings.marketingEventEnabled + ) + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "알림 설정 응답이 비어 있습니다" + Log.error("[ProfileRepositoryImpl] empty updateNotificationSettings payload: \(message)") + throw ProfileError.backendError(message) + } + + return data.toDomain() + } } diff --git a/Projects/Data/Service/Sources/Profile/NotificationSettingsRequest.swift b/Projects/Data/Service/Sources/Profile/NotificationSettingsRequest.swift new file mode 100644 index 00000000..dcc6ed49 --- /dev/null +++ b/Projects/Data/Service/Sources/Profile/NotificationSettingsRequest.swift @@ -0,0 +1,33 @@ +// +// NotificationSettingsRequest.swift +// Service +// +// `PATCH /api/v1/me/notification-settings` 요청 body. +// + +import Foundation + +public struct NotificationSettingsRequest: Encodable { + public let newBattleEnabled: Bool + public let battleResultEnabled: Bool + public let commentReplyEnabled: Bool + public let newCommentEnabled: Bool + public let contentLikeEnabled: Bool + public let marketingEventEnabled: Bool + + public init( + newBattleEnabled: Bool, + battleResultEnabled: Bool, + commentReplyEnabled: Bool, + newCommentEnabled: Bool, + contentLikeEnabled: Bool, + marketingEventEnabled: Bool + ) { + self.newBattleEnabled = newBattleEnabled + self.battleResultEnabled = battleResultEnabled + self.commentReplyEnabled = commentReplyEnabled + self.newCommentEnabled = newCommentEnabled + self.contentLikeEnabled = contentLikeEnabled + self.marketingEventEnabled = marketingEventEnabled + } +} diff --git a/Projects/Data/Service/Sources/Profile/ProfileService.swift b/Projects/Data/Service/Sources/Profile/ProfileService.swift index dc9a9169..75f6405b 100644 --- a/Projects/Data/Service/Sources/Profile/ProfileService.swift +++ b/Projects/Data/Service/Sources/Profile/ProfileService.swift @@ -15,6 +15,8 @@ public enum ProfileService { case creditsHistory(query: CreditHistoryQueryRequest) case battleRecords(query: BattleRecordsQueryRequest) case contentActivities(query: ContentActivitiesQueryRequest) + case notificationSettings + case updateNotificationSettings(body: NotificationSettingsRequest) } extension ProfileService: BaseTargetType { @@ -32,6 +34,10 @@ extension ProfileService: BaseTargetType { return ProfileAPI.battleRecords.description case .contentActivities: return ProfileAPI.contentActivities.description + case .notificationSettings: + return ProfileAPI.notificationSettings.description + case .updateNotificationSettings: + return ProfileAPI.notificationSettings.description } } @@ -39,8 +45,10 @@ extension ProfileService: BaseTargetType { public var method: Moya.Method { switch self { - case .mypage, .creditsHistory, .battleRecords, .contentActivities: + case .mypage, .creditsHistory, .battleRecords, .contentActivities, .notificationSettings: return .get + case .updateNotificationSettings: + return .patch } } @@ -57,6 +65,11 @@ extension ProfileService: BaseTargetType { case let .contentActivities(query): guard let dict = query.toDictionary else { return nil } return dict.isEmpty ? nil : dict + case .notificationSettings: + return nil + case let .updateNotificationSettings(body): + guard let dict = body.toDictionary else { return nil } + return dict.isEmpty ? nil : dict } } diff --git a/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift index 80acad2c..4352fa30 100644 --- a/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift @@ -56,4 +56,12 @@ public struct DefaultProfileRepositoryImpl: ProfileInterface { ) async throws -> ContentActivityPage { ContentActivityPage(items: [], nextOffset: 0, hasNext: false) } + + public func fetchNotificationSettings() async throws -> NotificationSettings { + NotificationSettings() + } + + public func updateNotificationSettings(_: NotificationSettings) async throws -> NotificationSettings { + NotificationSettings() + } } diff --git a/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift index 34eca088..5bc6ef95 100644 --- a/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift @@ -23,6 +23,8 @@ public protocol ProfileInterface: Sendable { size: Int, activityType: ContentActivityType? ) async throws -> ContentActivityPage + func fetchNotificationSettings() async throws -> NotificationSettings + func updateNotificationSettings(_ settings: NotificationSettings) async throws -> NotificationSettings } public struct ProfileRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettingKey.swift b/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettingKey.swift new file mode 100644 index 00000000..e27f74b5 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettingKey.swift @@ -0,0 +1,48 @@ +// +// NotificationSettingKey.swift +// Entity +// + +import Foundation + +/// 알림 설정 항목. +public enum NotificationSettingKey: String, CaseIterable, Identifiable, Equatable { + case newBattle + case battleResult + case commentReply + case newComment + case contentLike + case marketingEvent + + public var id: String { rawValue } + + public var title: String { + switch self { + case .newBattle: "새 배틀 알림" + case .battleResult: "투표 결과 알림" + case .commentReply: "내 댓글에 답글 알림" + case .newComment: "새 댓글 알림" + case .contentLike: "좋아요 알림" + case .marketingEvent: "이벤트 및 소식 알림" + } + } + + public var subtitle: String { + switch self { + case .newBattle: "관심 분야의 새로운 배틀이 열리면 알려드려요" + case .battleResult: "참여한 배틀의 최종 결과를 알려드려요" + case .commentReply: "내 의견에 답글이 달리면 알려드려요" + case .newComment: "참여한 배틀에 새 댓글이 달리면 알려드려요" + case .contentLike: "내 의견에 좋아요가 눌리면 알려드려요" + case .marketingEvent: "다양한 이벤트와 새로운 소식을 알려드려요" + } + } + + public var section: NotificationSettingSection { + switch self { + case .newBattle, .battleResult: .feature + case .commentReply, .newComment, .contentLike: .social + case .marketingEvent: .marketing + } + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettingSection.swift b/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettingSection.swift new file mode 100644 index 00000000..9cac5060 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettingSection.swift @@ -0,0 +1,28 @@ +// +// NotificationSettingSection.swift +// Entity +// + +import Foundation + +/// 알림 설정 섹션. +public enum NotificationSettingSection: String, CaseIterable, Identifiable, Equatable { + case feature + case social + case marketing + + public var id: String { rawValue } + + public var title: String { + switch self { + case .feature: "기능별 알림 설정" + case .social: "소셜 알림 설정" + case .marketing: "마케팅 알림 설정" + } + } + + /// 섹션에 속한 항목들. + public var keys: [NotificationSettingKey] { + NotificationSettingKey.allCases.filter { $0.section == self } + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettings.swift b/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettings.swift new file mode 100644 index 00000000..f5571906 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettings.swift @@ -0,0 +1,59 @@ +// +// NotificationSettings.swift +// Entity +// +// `GET/PATCH /api/v1/me/notification-settings` 도메인 모델 (6개 토글). +// + +import Foundation + +public struct NotificationSettings: Equatable { + public var newBattleEnabled: Bool + public var battleResultEnabled: Bool + public var commentReplyEnabled: Bool + public var newCommentEnabled: Bool + public var contentLikeEnabled: Bool + public var marketingEventEnabled: Bool + + public init( + newBattleEnabled: Bool = false, + battleResultEnabled: Bool = false, + commentReplyEnabled: Bool = false, + newCommentEnabled: Bool = false, + contentLikeEnabled: Bool = false, + marketingEventEnabled: Bool = false + ) { + self.newBattleEnabled = newBattleEnabled + self.battleResultEnabled = battleResultEnabled + self.commentReplyEnabled = commentReplyEnabled + self.newCommentEnabled = newCommentEnabled + self.contentLikeEnabled = contentLikeEnabled + self.marketingEventEnabled = marketingEventEnabled + } + + /// 키별 on/off 조회. + public func isOn(_ key: NotificationSettingKey) -> Bool { + switch key { + case .newBattle: newBattleEnabled + case .battleResult: battleResultEnabled + case .commentReply: commentReplyEnabled + case .newComment: newCommentEnabled + case .contentLike: contentLikeEnabled + case .marketingEvent: marketingEventEnabled + } + } + + /// 키별 on/off 설정 (불변 갱신). + public func setting(_ key: NotificationSettingKey, to value: Bool) -> NotificationSettings { + var copy = self + switch key { + case .newBattle: copy.newBattleEnabled = value + case .battleResult: copy.battleResultEnabled = value + case .commentReply: copy.commentReplyEnabled = value + case .newComment: copy.newCommentEnabled = value + case .contentLike: copy.contentLikeEnabled = value + case .marketingEvent: copy.marketingEventEnabled = value + } + return copy + } +} diff --git a/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift index 468612ad..da414a83 100644 --- a/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift +++ b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift @@ -49,6 +49,14 @@ public struct ProfileUseCaseImpl: ProfileInterface { activityType: activityType ) } + + public func fetchNotificationSettings() async throws -> NotificationSettings { + return try await profileRepository.fetchNotificationSettings() + } + + public func updateNotificationSettings(_ settings: NotificationSettings) async throws -> NotificationSettings { + return try await profileRepository.updateNotificationSettings(settings) + } } extension ProfileUseCaseImpl: DependencyKey { diff --git a/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordView.swift b/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordView.swift index fffacd71..a1c1a531 100644 --- a/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordView.swift +++ b/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordView.swift @@ -65,6 +65,7 @@ private extension BattleRecordView { .padding(.horizontal, 16) } .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) } } diff --git a/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift b/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift index 7c51afbc..e3f28d33 100644 --- a/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift +++ b/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift @@ -29,17 +29,38 @@ public struct ContentActivityView: View { tabBar() - if store.isLoading { - ContentActivitySkeletonView() - } else { - content() + Group { + if store.isLoading { + ContentActivitySkeletonView() + } else { + content() + } } + // 좌우 스와이프로 탭 전환 (CommentView 와 동일한 제스처 UX) + .simultaneousGesture(tabSwipeGesture()) } .background(Color.beige200.ignoresSafeArea()) .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) .onAppear { send(.onAppear) } } + + /// 좌우 드래그 → 인접 탭 전환. + func tabSwipeGesture() -> some Gesture { + DragGesture(minimumDistance: 20) + .onEnded { value in + let horizontal = value.translation.width + let vertical = value.translation.height + guard abs(horizontal) > abs(vertical), abs(horizontal) > 50 else { return } + let tabs = ContentActivityType.allCases + guard let index = tabs.firstIndex(of: store.selectedTab) else { return } + if horizontal < 0, index < tabs.count - 1 { + send(.tabSelected(tabs[index + 1])) + } else if horizontal > 0, index > 0 { + send(.tabSelected(tabs[index - 1])) + } + } + } } private extension ContentActivityView { @@ -104,6 +125,7 @@ private extension ContentActivityView { .padding(.horizontal, 16) } .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) } } diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift index 177eed25..f23e94d4 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift @@ -93,6 +93,15 @@ extension ProfileCoordinator { case .routeAction(_, action: .settings(.delegate(.dismiss))): return .send(.view(.backAction)) + // 알림 설정 → 알림 설정 화면. + case .routeAction(_, action: .settings(.delegate(.openNotificationSettings))): + state.routes.push(.notificationSetting(.init())) + return .none + + // 알림 설정 백탭 → 뒤로. + case .routeAction(_, action: .notificationSetting(.delegate(.dismiss))): + return .send(.view(.backAction)) + // 개인정보 처리방침 → 웹뷰. case .routeAction(_, action: .settings(.delegate(.openPrivacy))): state.routes.push(.web(.init(url: TermsDocument.privacy.urlString))) @@ -161,6 +170,7 @@ extension ProfileCoordinator { case battleRecord(BattleRecordFeature) case contentActivity(ContentActivityFeature) case notice(NoticeFeature) + case notificationSetting(NotificationSettingFeature) case web(WebReducer) } } diff --git a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift index 415f723a..c84ec671 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift @@ -33,6 +33,8 @@ public struct ProfileCoordinatorView: View { ContentActivityView(store: contentActivityStore) case let .notice(noticeStore): NoticeView(store: noticeStore) + case let .notificationSetting(notificationStore): + NotificationSettingView(store: notificationStore) case let .web(webStore): WebView(store: webStore) } diff --git a/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift b/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift index 6c635569..38a3e732 100644 --- a/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift +++ b/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift @@ -28,11 +28,31 @@ public struct NoticeView: View { tabBar() PickeEmptyStateView(message: emptyMessage) + // 좌우 스와이프로 탭 전환 + .contentShape(Rectangle()) + .simultaneousGesture(tabSwipeGesture()) } .background(Color.beige200.ignoresSafeArea()) .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) } + + /// 좌우 드래그 → 인접 탭 전환. + func tabSwipeGesture() -> some Gesture { + DragGesture(minimumDistance: 20) + .onEnded { value in + let horizontal = value.translation.width + let vertical = value.translation.height + guard abs(horizontal) > abs(vertical), abs(horizontal) > 50 else { return } + let tabs = NoticeTab.allCases + guard let index = tabs.firstIndex(of: store.selectedTab) else { return } + if horizontal < 0, index < tabs.count - 1 { + send(.tabSelected(tabs[index + 1])) + } else if horizontal > 0, index > 0 { + send(.tabSelected(tabs[index - 1])) + } + } + } } private extension NoticeView { diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift b/Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift new file mode 100644 index 00000000..1bbe6f48 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift @@ -0,0 +1,160 @@ +// +// NotificationSettingFeature.swift +// Profile +// +// 알림 설정 — picke.pen `알림 설정`. +// GET /me/notification-settings 로드 + 토글 변경 시 PATCH (낙관적 갱신). +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro +import UseCase + +@Reducer +public struct NotificationSettingFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var isLoading: Bool = false + public var settings: NotificationSettings = .init() + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backTapped + case toggle(NotificationSettingKey) + } + + public enum AsyncAction: Equatable { + case fetch + case update(NotificationSettings) + } + + public enum InnerAction: Equatable { + case settingsResponse(Result) + } + + public enum DelegateAction: Equatable { + case dismiss + } + + nonisolated enum CancelID: Hashable { + case fetch + case update + } + + @Dependency(\.profileUseCase) private var profileUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension NotificationSettingFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + return .send(.async(.fetch)) + + case .backTapped: + return .send(.delegate(.dismiss)) + + case let .toggle(key): + // 낙관적 갱신 후 PATCH. + let updated = state.settings.setting(key, to: !state.settings.isOn(key)) + state.settings = updated + return .send(.async(.update(updated))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetch: + state.isLoading = true + return .run { [useCase = profileUseCase] send in + let result = await Result { + try await useCase.fetchNotificationSettings() + } + .mapError(ProfileError.from) + return await send(.inner(.settingsResponse(result))) + } + .cancellable(id: CancelID.fetch, cancelInFlight: true) + + case let .update(settings): + return .run { [useCase = profileUseCase] send in + let result = await Result { + try await useCase.updateNotificationSettings(settings) + } + .mapError(ProfileError.from) + return await send(.inner(.settingsResponse(result))) + } + .cancellable(id: CancelID.update, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .settingsResponse(result): + state.isLoading = false + switch result { + case let .success(settings): + state.settings = settings + case let .failure(error): + Log.error("[NotificationSettingFeature] settings request failed: \(error.localizedDescription)") + } + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift b/Projects/Presentation/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift new file mode 100644 index 00000000..c53675eb --- /dev/null +++ b/Projects/Presentation/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift @@ -0,0 +1,53 @@ +// +// NotificationSettingSkeletonView.swift +// Profile +// +// 알림 설정 로딩 스켈레톤 — 공통 SkeletonBlock(light). +// + +import SwiftUI + +import DesignSystem + +struct NotificationSettingSkeletonView: View { + /// 섹션별 행 개수 (기능별 2 / 소셜 3 / 마케팅 1). + private let sectionRowCounts = [2, 3, 1] + + var body: some View { + VStack(spacing: 20) { + ForEach(Array(sectionRowCounts.enumerated()), id: \.offset) { _, rowCount in + VStack(alignment: .leading, spacing: 4) { + block(width: 90, height: 12) + .padding(.bottom, 4) + + VStack(spacing: 0) { + ForEach(0 ..< rowCount, id: \.self) { _ in + HStack(spacing: 4) { + VStack(alignment: .leading, spacing: 4) { + block(width: 120, height: 13) + block(width: 180, height: 11) + } + Spacer(minLength: 8) + SkeletonBlock(cornerRadius: 9, tone: .light) + .frame(width: 32, height: 18) + } + .padding(.vertical, 16) + .overlay(alignment: .bottom) { + Rectangle().fill(.beige600).frame(height: 1) + } + } + } + } + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + .frame(maxHeight: .infinity, alignment: .top) + } + + @ViewBuilder + private func block(width: CGFloat, height: CGFloat) -> some View { + SkeletonBlock(cornerRadius: 4, tone: .light) + .frame(width: width, height: height) + } +} diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift b/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift new file mode 100644 index 00000000..98043b53 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift @@ -0,0 +1,114 @@ +// +// NotificationSettingView.swift +// Profile +// +// 알림 설정 UI — picke.pen `알림 설정`. +// App Bar + 섹션(기능별/소셜/마케팅) + 토글 행. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +@ViewAction(for: NotificationSettingFeature.self) +public struct NotificationSettingView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "알림 설정") + .foregroundStyle(.gray500) + + if store.isLoading { + NotificationSettingSkeletonView() + } else { + ScrollView { + VStack(spacing: 20) { + ForEach(NotificationSettingSection.allCases) { section in + sectionView(section) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + } + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + } +} + +private extension NotificationSettingView { + @ViewBuilder + func sectionView(_ section: NotificationSettingSection) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(section.title) + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.gray300) + .padding(.bottom, 4) + + VStack(spacing: 0) { + ForEach(section.keys) { key in + settingRow(key) + } + } + } + } + + @ViewBuilder + func settingRow(_ key: NotificationSettingKey) -> some View { + HStack(spacing: 4) { + VStack(alignment: .leading, spacing: 4) { + Text(key.title) + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(.gray800) + + Text(key.subtitle) + .pretendardFont(family: .Regular, size: 11) + .foregroundStyle(.gray300) + } + + Spacer(minLength: 8) + + Button { + send(.toggle(key)) + } label: { + NotificationToggle(isOn: store.settings.isOn(key)) + } + .buttonStyle(.plain) + } + .padding(.vertical, 16) + .overlay(alignment: .bottom) { + Rectangle().fill(.beige600).frame(height: 1) + } + } +} + +/// picke.pen 토글 (32×18, ON=primary500 / OFF=neutral200, knob 14). +private struct NotificationToggle: View { + let isOn: Bool + + var body: some View { + ZStack(alignment: isOn ? .trailing : .leading) { + Capsule() + .fill(isOn ? Color.primary500 : Color.neutral200) + + Circle() + .fill(.beige50) + .frame(width: 14, height: 14) + .padding(2) + } + .frame(width: 32, height: 18) + .animation(.easeInOut(duration: 0.15), value: isOn) + } +} diff --git a/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift b/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift index e1fee26f..d7c6bdda 100644 --- a/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift +++ b/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift @@ -83,6 +83,7 @@ private extension PointHistoryView { .padding(.horizontal, 16) } .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) } } From d19485cbd5b60c38c21d5e32153635eefc56f74f Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 01:52:14 +0900 Subject: [PATCH 10/39] =?UTF-8?q?fix:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=A2=85=20=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=E2=86=92=20=EC=95=8C=EB=A6=BC=20=EC=84=A4=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=97=B0=EA=B2=B0=20+=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=94=A9=20=EC=A0=95=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상단 종(알림) 아이콘 openNotification → 알림 설정 화면 push (미연결 상태였음) - 알림 설정 섹션 헤더↔행 간격을 picke.pen 기준 4 로 정합 #10 --- .../Sources/Coordinator/Reducer/ProfileCoordinator.swift | 5 +++++ .../View/Components/NotificationSettingSkeletonView.swift | 1 - .../NotificationSetting/View/NotificationSettingView.swift | 1 - 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift index f23e94d4..c3cc734a 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift @@ -89,6 +89,11 @@ extension ProfileCoordinator { state.routes.push(.settings(.init())) return .none + // 알림(종) 아이콘 → 알림 설정 화면 진입. + case .routeAction(_, action: .profile(.delegate(.openNotification))): + state.routes.push(.notificationSetting(.init())) + return .none + // 설정 상단 백탭 → 뒤로. case .routeAction(_, action: .settings(.delegate(.dismiss))): return .send(.view(.backAction)) diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift b/Projects/Presentation/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift index c53675eb..c54ee745 100644 --- a/Projects/Presentation/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift +++ b/Projects/Presentation/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift @@ -18,7 +18,6 @@ struct NotificationSettingSkeletonView: View { ForEach(Array(sectionRowCounts.enumerated()), id: \.offset) { _, rowCount in VStack(alignment: .leading, spacing: 4) { block(width: 90, height: 12) - .padding(.bottom, 4) VStack(spacing: 0) { ForEach(0 ..< rowCount, id: \.self) { _ in diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift b/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift index 98043b53..958ec2d1 100644 --- a/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift +++ b/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift @@ -55,7 +55,6 @@ private extension NotificationSettingView { Text(section.title) .pretendardFont(family: .SemiBold, size: 12) .foregroundStyle(.gray300) - .padding(.bottom, 4) VStack(spacing: 0) { ForEach(section.keys) { key in From 472432b42715ade6a1329934d108081461c43ad4 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 02:09:43 +0900 Subject: [PATCH 11/39] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=A3=BC?= =?UTF-8?q?=EC=A0=9C=20=EC=A0=9C=EC=95=88=20=ED=99=94=EB=A9=B4/API=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20+=20=ED=82=A4=EB=B3=B4=EB=93=9C=20dismiss?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20#16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마이페이지에서 배틀 주제 제안 플로우를 열고, 제안 API 전 계층과 입력 화면을 연결하며 배경 탭 시 키보드가 내려가도록 포커스 해제를 적용한다. --- .../Data/API/Sources/Battle/BattleAPI.swift | 3 + .../Battle/DTO/BattleProposalDataDTO.swift | 21 ++ .../Mapper/BattleProposalDataDTO+.swift | 32 +++ .../Sources/Battle/BattleRepositoryImpl.swift | 22 ++ .../Battle/BattleProposalRequest.swift | 45 ++++ .../Sources/Battle/BattleService.swift | 7 +- .../Sources/Battle/BattleInterface.swift | 1 + .../Battle/DefaultBattleRepositoryImpl.swift | 15 ++ .../Battle/Proposal/BattleProposal.swift | 46 ++++ .../Proposal/BattleProposalCategory.swift | 22 ++ .../Battle/Proposal/BattleProposalDraft.swift | 30 +++ .../Sources/Battle/BattleUseCase.swift | 4 + .../Reducer/BattleProposalFeature.swift | 217 +++++++++++++++++ .../View/BattleProposalView.swift | 223 ++++++++++++++++++ .../Reducer/ProfileCoordinator.swift | 13 +- .../View/ProfileCoordinatorView.swift | 2 + 16 files changed, 699 insertions(+), 4 deletions(-) create mode 100644 Projects/Data/Model/Sources/Battle/DTO/BattleProposalDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Battle/Mapper/BattleProposalDataDTO+.swift create mode 100644 Projects/Data/Service/Sources/Battle/BattleProposalRequest.swift create mode 100644 Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposal.swift create mode 100644 Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposalCategory.swift create mode 100644 Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposalDraft.swift create mode 100644 Projects/Presentation/Profile/Sources/BattleProposal/Reducer/BattleProposalFeature.swift create mode 100644 Projects/Presentation/Profile/Sources/BattleProposal/View/BattleProposalView.swift diff --git a/Projects/Data/API/Sources/Battle/BattleAPI.swift b/Projects/Data/API/Sources/Battle/BattleAPI.swift index e14a3e56..50388411 100644 --- a/Projects/Data/API/Sources/Battle/BattleAPI.swift +++ b/Projects/Data/API/Sources/Battle/BattleAPI.swift @@ -15,6 +15,7 @@ public enum BattleAPI { case perspectives(battleId: Int) case myPerspective(battleId: Int) case recommendations(battleId: Int) + case proposals public var description: String { switch self { @@ -36,6 +37,8 @@ public enum BattleAPI { return "\(battleId)/perspectives/me" case let .recommendations(battleId): return "\(battleId)/recommendations/interesting" + case .proposals: + return "proposals" } } } diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleProposalDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleProposalDataDTO.swift new file mode 100644 index 00000000..0a337802 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleProposalDataDTO.swift @@ -0,0 +1,21 @@ +// +// BattleProposalDataDTO.swift +// Model +// + +import Foundation + +public struct BattleProposalDataDTO: Decodable { + public let id: Int? + public let userId: Int? + public let nickname: String? + public let category: String? + public let topic: String? + public let positionA: String? + public let positionB: String? + public let description: String? + public let status: String? + public let createdAt: String? +} + +public typealias BattleProposalResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleProposalDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleProposalDataDTO+.swift new file mode 100644 index 00000000..9059d9b0 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleProposalDataDTO+.swift @@ -0,0 +1,32 @@ +// +// BattleProposalDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension BattleProposalDataDTO { + func toDomain() -> BattleProposal { + BattleProposal( + id: id ?? 0, + userId: userId ?? 0, + nickname: nickname ?? "", + category: category ?? "", + topic: topic ?? "", + positionA: positionA ?? "", + positionB: positionB ?? "", + description: description ?? "", + status: status ?? "", + createdAt: createdAt.flatMap(Self.parseISO8601) + ) + } + + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index d4aa8687..bbc8e78b 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -195,4 +195,26 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { return data.toDomain() } + + public func proposeBattle(_ draft: BattleProposalDraft) async throws -> BattleProposal { + let dto: BattleProposalResponseDTO = try await provider.request( + .createProposal( + body: BattleProposalRequest( + category: draft.category, + topic: draft.topic, + positionA: draft.positionA, + positionB: draft.positionB, + description: draft.description + ) + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "배틀 제안 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty proposeBattle payload: \(message)") + throw BattleError.backendError(message) + } + + return data.toDomain() + } } diff --git a/Projects/Data/Service/Sources/Battle/BattleProposalRequest.swift b/Projects/Data/Service/Sources/Battle/BattleProposalRequest.swift new file mode 100644 index 00000000..480e2047 --- /dev/null +++ b/Projects/Data/Service/Sources/Battle/BattleProposalRequest.swift @@ -0,0 +1,45 @@ +// +// BattleProposalRequest.swift +// Service +// + +import Foundation + +public struct BattleProposalRequest: Encodable { + public let category: String + public let topic: String + public let positionA: String + public let positionB: String + public let description: String + + public init( + category: String, + topic: String, + positionA: String, + positionB: String, + description: String + ) { + self.category = category + self.topic = topic + self.positionA = positionA + self.positionB = positionB + self.description = description + } + + private enum CodingKeys: String, CodingKey { + case category + case topic + case positionA + case positionB + case description + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(category, forKey: .category) + try container.encode(topic, forKey: .topic) + try container.encode(positionA, forKey: .positionA) + try container.encode(positionB, forKey: .positionB) + try container.encode(description, forKey: .description) + } +} diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index a77e3965..94deef2c 100644 --- a/Projects/Data/Service/Sources/Battle/BattleService.swift +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -21,6 +21,7 @@ public enum BattleService { case createPerspective(battleId: Int, body: CreatePerspectiveRequest) case myPerspective(battleId: Int) case recommendations(battleId: Int) + case createProposal(body: BattleProposalRequest) } extension BattleService: BaseTargetType { @@ -50,6 +51,8 @@ extension BattleService: BaseTargetType { return BattleAPI.myPerspective(battleId: battleId).description case let .recommendations(battleId): return BattleAPI.recommendations(battleId: battleId).description + case .createProposal: + return BattleAPI.proposals.description } } @@ -59,7 +62,7 @@ extension BattleService: BaseTargetType { switch self { case .today, .detail, .scenario, .voteStats, .perspectives, .myPerspective, .recommendations: return .get - case .preVote, .postVote, .createPerspective: + case .preVote, .postVote, .createPerspective, .createProposal: return .post } } @@ -83,6 +86,8 @@ extension BattleService: BaseTargetType { return dict.isEmpty ? nil : dict case let .createPerspective(_, body): return body.toDictionary + case let .createProposal(body): + return body.toDictionary case .myPerspective: return nil case .recommendations: diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift index db3452c3..ff8c2e8d 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -34,6 +34,7 @@ public protocol BattleInterface: Sendable { ) async throws -> BattlePerspective func fetchMyPerspective(battleId: Int) async throws -> BattlePerspective? func fetchRecommendedBattles(battleId: Int) async throws -> RecommendedBattlePage + func proposeBattle(_ draft: BattleProposalDraft) async throws -> BattleProposal } public struct BattleRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift index 23eced77..d86add59 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -102,4 +102,19 @@ public struct DefaultBattleRepositoryImpl: BattleInterface { public func fetchRecommendedBattles(battleId _: Int) async throws -> RecommendedBattlePage { RecommendedBattlePage(items: [], nextCursor: nil, hasNext: false) } + + public func proposeBattle(_: BattleProposalDraft) async throws -> BattleProposal { + BattleProposal( + id: 0, + userId: 0, + nickname: "", + category: "", + topic: "", + positionA: "", + positionB: "", + description: "", + status: "", + createdAt: nil + ) + } } diff --git a/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposal.swift b/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposal.swift new file mode 100644 index 00000000..693aaf12 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposal.swift @@ -0,0 +1,46 @@ +// +// BattleProposal.swift +// Entity +// +// 배틀 주제 제안 결과 (POST /api/v1/battles/proposals 응답). +// + +import Foundation + +public struct BattleProposal: Equatable, Identifiable { + public let id: Int + public let userId: Int + public let nickname: String + public let category: String + public let topic: String + public let positionA: String + public let positionB: String + public let description: String + /// 제안 상태 (예: `PENDING`). + public let status: String + public let createdAt: Date? + + public init( + id: Int, + userId: Int, + nickname: String, + category: String, + topic: String, + positionA: String, + positionB: String, + description: String, + status: String, + createdAt: Date? + ) { + self.id = id + self.userId = userId + self.nickname = nickname + self.category = category + self.topic = topic + self.positionA = positionA + self.positionB = positionB + self.description = description + self.status = status + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposalCategory.swift b/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposalCategory.swift new file mode 100644 index 00000000..ac2779e2 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposalCategory.swift @@ -0,0 +1,22 @@ +// +// BattleProposalCategory.swift +// Entity +// +// 배틀 주제 제안 카테고리 (POST /api/v1/battles/proposals 의 category). +// + +import Foundation + +public enum BattleProposalCategory: String, CaseIterable, Identifiable, Equatable { + case philosophy = "철학" + case literature = "문학" + case art = "예술" + case science = "과학" + case society = "사회" + case history = "역사" + + public var id: String { rawValue } + + /// 표시명 (= API 전송값). + public var title: String { rawValue } +} diff --git a/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposalDraft.swift b/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposalDraft.swift new file mode 100644 index 00000000..db79f229 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposalDraft.swift @@ -0,0 +1,30 @@ +// +// BattleProposalDraft.swift +// Entity +// +// 배틀 주제 제안 입력 묶음 (POST /api/v1/battles/proposals 요청 값). +// + +import Foundation + +public struct BattleProposalDraft: Equatable { + public let category: String + public let topic: String + public let positionA: String + public let positionB: String + public let description: String + + public init( + category: String, + topic: String, + positionA: String, + positionB: String, + description: String + ) { + self.category = category + self.topic = topic + self.positionA = positionA + self.positionB = positionB + self.description = description + } +} diff --git a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift index b3bb82c3..c5af1b21 100644 --- a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift +++ b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift @@ -80,6 +80,10 @@ public struct BattleUseCaseImpl: BattleInterface { public func fetchRecommendedBattles(battleId: Int) async throws -> RecommendedBattlePage { return try await battleRepository.fetchRecommendedBattles(battleId: battleId) } + + public func proposeBattle(_ draft: BattleProposalDraft) async throws -> BattleProposal { + return try await battleRepository.proposeBattle(draft) + } } extension BattleUseCaseImpl: DependencyKey { diff --git a/Projects/Presentation/Profile/Sources/BattleProposal/Reducer/BattleProposalFeature.swift b/Projects/Presentation/Profile/Sources/BattleProposal/Reducer/BattleProposalFeature.swift new file mode 100644 index 00000000..68c7c3ad --- /dev/null +++ b/Projects/Presentation/Profile/Sources/BattleProposal/Reducer/BattleProposalFeature.swift @@ -0,0 +1,217 @@ +// +// BattleProposalFeature.swift +// Profile +// +// 배틀 주제 제안(배틀 만들기) — picke.pen `배틀 주제 제안`. +// 카테고리/주제/양측입장/부가설명 입력 → POST /api/v1/battles/proposals. +// + +import Foundation + +import ComposableArchitecture +import DesignSystem +import Entity +import LogMacro +import UseCase + +@Reducer +public struct BattleProposalFeature { + public init() {} + + static let descriptionLimit = 200 + + @ObservableState + public struct State: Equatable { + public var selectedCategory: BattleProposalCategory = .philosophy + public var topic: String = "" + public var positionA: String = "" + public var positionB: String = "" + public var description: String = "" + public var isSubmitting: Bool = false + @Presents public var customAlert: CustomAlertState? + + public init() {} + + /// 필수 항목(주제/양측 입장) 충족 시 제안 가능. + public var isSubmitEnabled: Bool { + !topic.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !positionA.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !positionB.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !isSubmitting + } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case scope(ScopeAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) + } + + @CasePathable + public enum View { + case backTapped + case categorySelected(BattleProposalCategory) + case submitTapped + } + + public enum AsyncAction: Equatable { + case submit + } + + public enum InnerAction: Equatable { + case submitResponse(Result) + } + + public enum DelegateAction: Equatable { + case dismiss + /// 제안 성공 → 화면 닫기. + case proposed(BattleProposal) + } + + nonisolated enum CancelID: Hashable { + case submit + } + + @Dependency(\.battleUseCase) private var battleUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + // 부가 설명 200자 제한. + if state.description.count > Self.descriptionLimit { + state.description = String(state.description.prefix(Self.descriptionLimit)) + } + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .scope(scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } + } +} + +extension BattleProposalFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backTapped: + return .send(.delegate(.dismiss)) + + case let .categorySelected(category): + state.selectedCategory = category + return .none + + case .submitTapped: + guard state.isSubmitEnabled else { return .none } + return .send(.async(.submit)) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .submit: + state.isSubmitting = true + let draft = BattleProposalDraft( + category: state.selectedCategory.title, + topic: state.topic, + positionA: state.positionA, + positionB: state.positionB, + description: state.description + ) + return .run { [useCase = battleUseCase] send in + let result = await Result { + try await useCase.proposeBattle(draft) + } + .mapError(BattleError.from) + return await send(.inner(.submitResponse(result))) + } + .cancellable(id: CancelID.submit, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .submitResponse(result): + state.isSubmitting = false + switch result { + case .success: + // 제안 성공 → 완료 팝업 노출 (확인 시 화면 닫음). + state.customAlert = .alert( + title: "제안이 완료되었어요", + message: "검토 후 배틀로 등록되면 알려드릴게요", + confirmTitle: "확인", + cancelTitle: "" + ) + return .none + case let .failure(error): + Log.error("[BattleProposalFeature] proposeBattle failed: \(error.localizedDescription)") + return .none + } + } + } + + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case let .customAlert(alertAction): + switch alertAction { + case .presented(.confirmTapped): + state.customAlert = nil + return .send(.delegate(.dismiss)) + case .presented(.cancelTapped): + state.customAlert = nil + return .none + case .dismiss: + state.customAlert = nil + return .none + } + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + case .proposed: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/BattleProposal/View/BattleProposalView.swift b/Projects/Presentation/Profile/Sources/BattleProposal/View/BattleProposalView.swift new file mode 100644 index 00000000..7139d9ae --- /dev/null +++ b/Projects/Presentation/Profile/Sources/BattleProposal/View/BattleProposalView.swift @@ -0,0 +1,223 @@ +// +// BattleProposalView.swift +// Profile +// +// 배틀 주제 제안(배틀 만들기) UI — picke.pen `배틀 주제 제안`. +// 카테고리 칩 + 주제 + 양측 입장(A/B) + 부가 설명 + 제안하기(-30P). +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +@ViewAction(for: BattleProposalFeature.self) +public struct BattleProposalView: View { + @Bindable public var store: StoreOf + @FocusState private var isInputFocused: Bool + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "배틀 만들기") + .foregroundStyle(.gray500) + + ScrollView { + VStack(spacing: 16) { + categoryField() + topicField() + stanceField() + descriptionField() + submitButton() + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + .background( + Color.beige200 + .onTapGesture { + isInputFocused = false + } + ) + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + } +} + +private extension BattleProposalView { + // MARK: 공통 라벨 + + @ViewBuilder + func fieldLabel(_ text: String) -> some View { + Text(text) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray400) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: 카테고리 + + @ViewBuilder + func categoryField() -> some View { + VStack(alignment: .leading, spacing: 4) { + fieldLabel("카테고리 *") + + HStack(spacing: 0) { + ForEach(Array(BattleProposalCategory.allCases.enumerated()), id: \.element.id) { index, category in + let isSelected = store.selectedCategory == category + Button { + send(.categorySelected(category)) + } label: { + Text(category.title) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(isSelected ? .beige50 : .gray300) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(isSelected ? Color.primary500 : Color.beige50) + .overlay(alignment: .trailing) { + if !isSelected, index < BattleProposalCategory.allCases.count - 1 { + Rectangle().fill(.beige600).frame(width: 1) + } + } + } + .buttonStyle(.plain) + } + } + .clipShape(RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + } + + // MARK: 주제 + + @ViewBuilder + func topicField() -> some View { + VStack(alignment: .leading, spacing: 4) { + fieldLabel("주제 *") + inputField(text: $store.topic, placeholder: "논쟁이 될만한 주제를 한 줄로 써주세요") + } + } + + // MARK: 양측 입장 + + @ViewBuilder + func stanceField() -> some View { + VStack(alignment: .leading, spacing: 4) { + fieldLabel("양측 입장 *") + + HStack(spacing: 8) { + Text("A") + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.primary500) + .frame(width: 20) + inputField(text: $store.positionA, placeholder: "첫 번째 입장을 입력하세요") + } + + HStack(spacing: 8) { + Text("B") + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.neutral900) + .frame(width: 20) + inputField(text: $store.positionB, placeholder: "두 번째 입장을 입력하세요") + } + } + } + + // MARK: 입력 필드 (한 줄) + + @ViewBuilder + func inputField(text: Binding, placeholder: String) -> some View { + ZStack(alignment: .leading) { + if text.wrappedValue.isEmpty { + Text(placeholder) + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(.gray300) + } + TextField("", text: text) + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(.gray800) + .focused($isInputFocused) + } + .padding(.leading, 8) + .frame(height: 44) + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + + // MARK: 부가 설명 + + @ViewBuilder + func descriptionField() -> some View { + VStack(alignment: .leading, spacing: 4) { + fieldLabel("부가 설명 (선택)") + + VStack(alignment: .trailing, spacing: 4) { + ZStack(alignment: .topLeading) { + if store.description.isEmpty { + Text("이 주제를 제안하는 이유나 배경을 자유롭게 써주세요") + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.gray300) + .padding(.top, 8) + .padding(.leading, 4) + } + TextEditor(text: $store.description) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.gray800) + .scrollContentBackground(.hidden) + .frame(height: 60) + .focused($isInputFocused) + } + + Text("\(store.description.count)/200") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.gray400) + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + } + + // MARK: 제안하기 + + @ViewBuilder + func submitButton() -> some View { + Button { + send(.submitTapped) + } label: { + Text("제안하기 (-30P)") + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.beige50) + .frame(maxWidth: .infinity) + .padding(.vertical, 17) + .background( + store.isSubmitEnabled ? Color.primary500 : Color.primary200, + in: RoundedRectangle(cornerRadius: 2) + ) + } + .buttonStyle(.plain) + .disabled(!store.isSubmitEnabled) + .padding(.top, 8) + } +} diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift index c3cc734a..142aa2d8 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift @@ -74,6 +74,15 @@ extension ProfileCoordinator { state.routes.push(.pointHistory(.init())) return .none + // 포인트 내역에서 주제 제안 → 배틀 만들기 화면 진입. + case .routeAction(_, action: .pointHistory(.delegate(.suggestTopic))): + state.routes.push(.battleProposal(.init())) + return .none + + // 배틀 만들기 닫기(취소/제안 완료) → 뒤로. + case .routeAction(_, action: .battleProposal(.delegate(.dismiss))): + return .send(.view(.backAction)) + // 포인트 내역 상단 백탭 → 뒤로. case .routeAction(_, action: .pointHistory(.delegate(.dismiss))): return .send(.view(.backAction)) @@ -81,9 +90,6 @@ extension ProfileCoordinator { case .routeAction(_, action: .profile(.delegate(.openPhilosopher))): return .none - case .routeAction(_, action: .profile(.delegate(.openNotification))): - return .none - // 설정 아이콘 → 설정 화면 진입. case .routeAction(_, action: .profile(.delegate(.openSettings))): state.routes.push(.settings(.init())) @@ -176,6 +182,7 @@ extension ProfileCoordinator { case contentActivity(ContentActivityFeature) case notice(NoticeFeature) case notificationSetting(NotificationSettingFeature) + case battleProposal(BattleProposalFeature) case web(WebReducer) } } diff --git a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift index c84ec671..56f86417 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift @@ -35,6 +35,8 @@ public struct ProfileCoordinatorView: View { NoticeView(store: noticeStore) case let .notificationSetting(notificationStore): NotificationSettingView(store: notificationStore) + case let .battleProposal(battleProposalStore): + BattleProposalView(store: battleProposalStore) case let .web(webStore): WebView(store: webStore) } From 0894e8a650295737e8d6522a19a2f0dcff3d976c Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 02:13:32 +0900 Subject: [PATCH 12/39] =?UTF-8?q?fix:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=A2=85=20=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=EC=84=A4=EC=A0=95=20=EC=A7=81=ED=96=89=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20#9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마이페이지 루트의 종 아이콘 이벤트는 유지하되 알림 설정 화면으로 바로 push 하던 라우팅만 제거한다. --- .../Sources/Coordinator/Reducer/ProfileCoordinator.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift index 142aa2d8..b1568da2 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift @@ -95,9 +95,8 @@ extension ProfileCoordinator { state.routes.push(.settings(.init())) return .none - // 알림(종) 아이콘 → 알림 설정 화면 진입. + // 알림(종) 아이콘은 현재 연결 화면 없음. case .routeAction(_, action: .profile(.delegate(.openNotification))): - state.routes.push(.notificationSetting(.init())) return .none // 설정 상단 백탭 → 뒤로. From 27c1dceec9be4c059456dfd84cce2dc3b81c8af5 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 08:21:54 +0900 Subject: [PATCH 13/39] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=A3=BC?= =?UTF-8?q?=EC=A0=9C=20=EC=A0=9C=EC=95=88(=EB=B0=B0=ED=8B=80=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0)=20+=20=EC=A0=9C=EC=95=88=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=20=ED=8C=9D=EC=97=85=20+=20=EB=B9=88=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=AC=B8=EA=B5=AC=20=EA=B0=80=EB=8F=85=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /battles/proposals 연동 (Clean Architecture, 파라미터를 BattleProposalDraft 로 묶음) - 배틀 만들기 폼: 카테고리 칩 + 주제 + 양측 입장(A/B) + 부가 설명(200자) + 제안하기(-30P) - 제안 성공 시 완료 팝업(CustomAlert) → 확인 시 화면 닫힘 - 포인트 내역 주제 제안 팝업 '제안하기' → 배틀 만들기 화면 push - PickeEmptyStateView 문구 색상 beige300 → gray300 (라이트 배경 가독성) #8 #16 --- .../Project+Templete/Extension+String.swift | 2 +- .../DesignSystem/Sources/UI/Empty/PickeEmptyStateView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift index 45b58a21..8b042781 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift @@ -17,7 +17,7 @@ extension String { return Project.Environment.bundlePrefix } - public static func appBuildVersion(buildVersion: String = "18") -> String { + public static func appBuildVersion(buildVersion: String = "19") -> String { return buildVersion } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Empty/PickeEmptyStateView.swift b/Projects/Shared/DesignSystem/Sources/UI/Empty/PickeEmptyStateView.swift index 1699ba19..f1a98e11 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Empty/PickeEmptyStateView.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Empty/PickeEmptyStateView.swift @@ -31,7 +31,7 @@ public struct PickeEmptyStateView: View { Text(message) .pretendardCustomFont(textStyle: .bodyMedium) - .foregroundStyle(.beige300) + .foregroundStyle(.gray300) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) From e03d937015d3a24a486438a499ab43662616d853 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 11:04:42 +0900 Subject: [PATCH 14/39] =?UTF-8?q?feat:=20=EB=82=98=EC=9D=98=20=EC=B2=A0?= =?UTF-8?q?=ED=95=99=EC=9E=90=20=EC=9C=A0=ED=98=95(=EB=A6=AC=EC=BA=A1)=20?= =?UTF-8?q?=E2=80=94=20GET=20/me/recap=20+=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20+=20=EC=B0=A8=ED=8A=B8=20?= =?UTF-8?q?=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /me/recap 연동 (Clean Architecture, Entity 1타입 1파일: PhilosopherRecap/RecapCard/RecapScores/RecapScoreAxis/PreferenceReport/FavoriteTopic) - 컴포넌트 분리(잠금 화면 재사용 대비): RecapPhilosopherCard / RecapRadarChart / RecapScoreBar / RecapMatchCard - 성향 분석: 육각 레이더 차트(중심→값 펼침 애니메이션) + 6축 점수 바(채움 애니메이션) - 내 취향 리포트(통계 3칸 + 선호 주제 랭킹), 궁합 BEST/WORST 2카드, 공유하기 - 마이페이지 '나의 철학자 유형' 카드 탭 → 리캡 화면 push #13 --- .../Data/API/Sources/Profile/ProfileAPI.swift | 3 + .../Sources/Profile/DTO/RecapDataDTO.swift | 49 ++++ .../Profile/Mapper/RecapDataDTO+.swift | 103 +++++++++ .../Profile/ProfileRepositoryImpl.swift | 12 + .../Sources/Profile/ProfileService.swift | 7 +- .../DefaultProfileRepositoryImpl.swift | 43 ++++ .../Sources/Profile/ProfileInterface.swift | 1 + .../Sources/Profile/Recap/FavoriteTopic.swift | 25 ++ .../Profile/Recap/PhilosopherRecap.swift | 30 +++ .../Profile/Recap/PreferenceReport.swift | 28 +++ .../Sources/Profile/Recap/RecapCard.swift | 34 +++ .../Profile/Recap/RecapScoreAxis.swift | 19 ++ .../Sources/Profile/Recap/RecapScores.swift | 45 ++++ .../Sources/Profile/ProfileUseCase.swift | 4 + .../Reducer/ProfileCoordinator.swift | 7 + .../View/ProfileCoordinatorView.swift | 2 + .../Sources/Recap/Reducer/RecapFeature.swift | 150 ++++++++++++ .../View/Components/RecapMatchCard.swift | 76 +++++++ .../Components/RecapPhilosopherCard.swift | 95 ++++++++ .../View/Components/RecapRadarChart.swift | 107 +++++++++ .../Recap/View/Components/RecapScoreBar.swift | 58 +++++ .../Sources/Recap/View/RecapView.swift | 214 ++++++++++++++++++ 22 files changed, 1111 insertions(+), 1 deletion(-) create mode 100644 Projects/Data/Model/Sources/Profile/DTO/RecapDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Profile/Mapper/RecapDataDTO+.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/Recap/FavoriteTopic.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/Recap/PhilosopherRecap.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/Recap/PreferenceReport.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/Recap/RecapCard.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/Recap/RecapScoreAxis.swift create mode 100644 Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift create mode 100644 Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift create mode 100644 Projects/Presentation/Profile/Sources/Recap/View/Components/RecapMatchCard.swift create mode 100644 Projects/Presentation/Profile/Sources/Recap/View/Components/RecapPhilosopherCard.swift create mode 100644 Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift create mode 100644 Projects/Presentation/Profile/Sources/Recap/View/Components/RecapScoreBar.swift create mode 100644 Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift diff --git a/Projects/Data/API/Sources/Profile/ProfileAPI.swift b/Projects/Data/API/Sources/Profile/ProfileAPI.swift index 02f0ae2b..3d2cc441 100644 --- a/Projects/Data/API/Sources/Profile/ProfileAPI.swift +++ b/Projects/Data/API/Sources/Profile/ProfileAPI.swift @@ -7,6 +7,7 @@ import Foundation public enum ProfileAPI { case mypage + case recap case creditsHistory case battleRecords case contentActivities @@ -16,6 +17,8 @@ public enum ProfileAPI { switch self { case .mypage: return "mypage" + case .recap: + return "recap" case .creditsHistory: return "credits/history" case .battleRecords: diff --git a/Projects/Data/Model/Sources/Profile/DTO/RecapDataDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/RecapDataDTO.swift new file mode 100644 index 00000000..7747a9e0 --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/DTO/RecapDataDTO.swift @@ -0,0 +1,49 @@ +// +// RecapDataDTO.swift +// Model +// +// `GET /api/v1/me/recap` 응답 DTO. +// + +import Foundation + +public struct RecapDataDTO: Decodable { + public let myCard: RecapCardDTO? + public let bestMatchCard: RecapCardDTO? + public let worstMatchCard: RecapCardDTO? + public let scores: RecapScoresDTO? + public let preferenceReport: PreferenceReportDTO? +} + +public struct RecapCardDTO: Decodable { + public let philosopherType: String? + public let philosopherLabel: String? + public let typeName: String? + public let description: String? + public let keywordTags: [String]? + public let imageUrl: String? +} + +public struct RecapScoresDTO: Decodable { + public let principle: Double? + public let reason: Double? + public let individual: Double? + public let change: Double? + public let inner: Double? + public let ideal: Double? +} + +public struct PreferenceReportDTO: Decodable { + public let totalParticipation: Int? + public let opinionChanges: Int? + public let battleWinRate: Int? + public let favoriteTopics: [FavoriteTopicDTO]? +} + +public struct FavoriteTopicDTO: Decodable { + public let rank: Int? + public let participationCount: Int? + public let tagName: String? +} + +public typealias RecapResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Profile/Mapper/RecapDataDTO+.swift b/Projects/Data/Model/Sources/Profile/Mapper/RecapDataDTO+.swift new file mode 100644 index 00000000..fb0ac302 --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/Mapper/RecapDataDTO+.swift @@ -0,0 +1,103 @@ +// +// RecapDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension RecapDataDTO { + func toDomain() -> PhilosopherRecap { + PhilosopherRecap( + myCard: myCard?.toDomain() ?? .empty, + bestMatchCard: bestMatchCard?.toDomain() ?? .empty, + worstMatchCard: worstMatchCard?.toDomain() ?? .empty, + scores: scores?.toDomain() ?? .empty, + preferenceReport: preferenceReport?.toDomain() ?? .empty + ) + } +} + +public extension RecapCardDTO { + func toDomain() -> RecapCard { + RecapCard( + philosopherType: philosopherType ?? "", + philosopherLabel: philosopherLabel ?? "", + typeName: typeName ?? "", + description: description ?? "", + keywordTags: keywordTags ?? [], + imageURL: imageUrl ?? "" + ) + } +} + +public extension RecapScoresDTO { + func toDomain() -> RecapScores { + RecapScores( + principle: principle ?? 0, + reason: reason ?? 0, + individual: individual ?? 0, + change: change ?? 0, + inner: inner ?? 0, + ideal: ideal ?? 0 + ) + } +} + +public extension PreferenceReportDTO { + func toDomain() -> PreferenceReport { + PreferenceReport( + totalParticipation: totalParticipation ?? 0, + opinionChanges: opinionChanges ?? 0, + battleWinRate: battleWinRate ?? 0, + favoriteTopics: (favoriteTopics ?? []).map { $0.toDomain() } + ) + } +} + +public extension FavoriteTopicDTO { + func toDomain() -> FavoriteTopic { + FavoriteTopic( + rank: rank ?? 0, + tagName: tagName ?? "", + participationCount: participationCount ?? 0 + ) + } +} + +private extension RecapCard { + static var empty: RecapCard { + RecapCard( + philosopherType: "", + philosopherLabel: "", + typeName: "", + description: "", + keywordTags: [], + imageURL: "" + ) + } +} + +private extension RecapScores { + static var empty: RecapScores { + RecapScores( + principle: 0, + reason: 0, + individual: 0, + change: 0, + inner: 0, + ideal: 0 + ) + } +} + +private extension PreferenceReport { + static var empty: PreferenceReport { + PreferenceReport( + totalParticipation: 0, + opinionChanges: 0, + battleWinRate: 0, + favoriteTopics: [] + ) + } +} diff --git a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift index 1d43b2c7..0501c171 100644 --- a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift @@ -36,6 +36,18 @@ public final class ProfileRepositoryImpl: ProfileInterface, @unchecked Sendable return data.toDomain() } + public func fetchRecap() async throws -> PhilosopherRecap { + let dto: RecapResponseDTO = try await provider.request(.recap) + + guard let data = dto.data else { + let message = dto.error?.message ?? "리캡 응답이 비어 있습니다" + Log.error("[ProfileRepositoryImpl] empty recap payload: \(message)") + throw ProfileError.backendError(message) + } + + return data.toDomain() + } + public func fetchCreditHistory( offset: Int, size: Int diff --git a/Projects/Data/Service/Sources/Profile/ProfileService.swift b/Projects/Data/Service/Sources/Profile/ProfileService.swift index 75f6405b..9ff10f15 100644 --- a/Projects/Data/Service/Sources/Profile/ProfileService.swift +++ b/Projects/Data/Service/Sources/Profile/ProfileService.swift @@ -12,6 +12,7 @@ import AsyncMoya public enum ProfileService { case mypage + case recap case creditsHistory(query: CreditHistoryQueryRequest) case battleRecords(query: BattleRecordsQueryRequest) case contentActivities(query: ContentActivitiesQueryRequest) @@ -28,6 +29,8 @@ extension ProfileService: BaseTargetType { switch self { case .mypage: return ProfileAPI.mypage.description + case .recap: + return ProfileAPI.recap.description case .creditsHistory: return ProfileAPI.creditsHistory.description case .battleRecords: @@ -45,7 +48,7 @@ extension ProfileService: BaseTargetType { public var method: Moya.Method { switch self { - case .mypage, .creditsHistory, .battleRecords, .contentActivities, .notificationSettings: + case .mypage, .recap, .creditsHistory, .battleRecords, .contentActivities, .notificationSettings: return .get case .updateNotificationSettings: return .patch @@ -56,6 +59,8 @@ extension ProfileService: BaseTargetType { switch self { case .mypage: return nil + case .recap: + return nil case let .creditsHistory(query): guard let dict = query.toDictionary else { return nil } return dict.isEmpty ? nil : dict diff --git a/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift index 4352fa30..35401b41 100644 --- a/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift @@ -34,6 +34,49 @@ public struct DefaultProfileRepositoryImpl: ProfileInterface { ) } + public func fetchRecap() async throws -> PhilosopherRecap { + PhilosopherRecap( + myCard: RecapCard( + philosopherType: "", + philosopherLabel: "", + typeName: "", + description: "", + keywordTags: [], + imageURL: "" + ), + bestMatchCard: RecapCard( + philosopherType: "", + philosopherLabel: "", + typeName: "", + description: "", + keywordTags: [], + imageURL: "" + ), + worstMatchCard: RecapCard( + philosopherType: "", + philosopherLabel: "", + typeName: "", + description: "", + keywordTags: [], + imageURL: "" + ), + scores: RecapScores( + principle: 0, + reason: 0, + individual: 0, + change: 0, + inner: 0, + ideal: 0 + ), + preferenceReport: PreferenceReport( + totalParticipation: 0, + opinionChanges: 0, + battleWinRate: 0, + favoriteTopics: [] + ) + ) + } + public func fetchCreditHistory( offset _: Int, size _: Int diff --git a/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift index 5bc6ef95..af998aaa 100644 --- a/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift @@ -9,6 +9,7 @@ import WeaveDI public protocol ProfileInterface: Sendable { func fetchMyPage() async throws -> MyPage + func fetchRecap() async throws -> PhilosopherRecap func fetchCreditHistory( offset: Int, size: Int diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/FavoriteTopic.swift b/Projects/Domain/Entity/Sources/Profile/Recap/FavoriteTopic.swift new file mode 100644 index 00000000..280d67f9 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Recap/FavoriteTopic.swift @@ -0,0 +1,25 @@ +// +// FavoriteTopic.swift +// Entity +// + +import Foundation + +public struct FavoriteTopic: Equatable, Identifiable { + public let rank: Int + public let tagName: String + public let participationCount: Int + + public var id: Int { rank } + + /// `#` 태그 표시. + public var tagText: String { + tagName.hasPrefix("#") ? tagName : "#\(tagName)" + } + + public init(rank: Int, tagName: String, participationCount: Int) { + self.rank = rank + self.tagName = tagName + self.participationCount = participationCount + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/PhilosopherRecap.swift b/Projects/Domain/Entity/Sources/Profile/Recap/PhilosopherRecap.swift new file mode 100644 index 00000000..3e45c6d7 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Recap/PhilosopherRecap.swift @@ -0,0 +1,30 @@ +// +// PhilosopherRecap.swift +// Entity +// +// `GET /api/v1/me/recap` 응답 — 나의 철학자 유형 리캡. +// + +import Foundation + +public struct PhilosopherRecap: Equatable { + public let myCard: RecapCard + public let bestMatchCard: RecapCard + public let worstMatchCard: RecapCard + public let scores: RecapScores + public let preferenceReport: PreferenceReport + + public init( + myCard: RecapCard, + bestMatchCard: RecapCard, + worstMatchCard: RecapCard, + scores: RecapScores, + preferenceReport: PreferenceReport + ) { + self.myCard = myCard + self.bestMatchCard = bestMatchCard + self.worstMatchCard = worstMatchCard + self.scores = scores + self.preferenceReport = preferenceReport + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/PreferenceReport.swift b/Projects/Domain/Entity/Sources/Profile/Recap/PreferenceReport.swift new file mode 100644 index 00000000..5efb928b --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Recap/PreferenceReport.swift @@ -0,0 +1,28 @@ +// +// PreferenceReport.swift +// Entity +// +// 내 취향 리포트 (통계 + 선호 주제 랭킹). +// + +import Foundation + +public struct PreferenceReport: Equatable { + public let totalParticipation: Int + public let opinionChanges: Int + /// 배틀 승률 (%). + public let battleWinRate: Int + public let favoriteTopics: [FavoriteTopic] + + public init( + totalParticipation: Int, + opinionChanges: Int, + battleWinRate: Int, + favoriteTopics: [FavoriteTopic] + ) { + self.totalParticipation = totalParticipation + self.opinionChanges = opinionChanges + self.battleWinRate = battleWinRate + self.favoriteTopics = favoriteTopics + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/RecapCard.swift b/Projects/Domain/Entity/Sources/Profile/Recap/RecapCard.swift new file mode 100644 index 00000000..4fa5f41c --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Recap/RecapCard.swift @@ -0,0 +1,34 @@ +// +// RecapCard.swift +// Entity +// +// 철학자 카드 (내 카드 / 궁합 best·worst). +// + +import Foundation + +public struct RecapCard: Equatable { + public let philosopherType: String + public let philosopherLabel: String + /// 유형명 (예: `칸트형`). + public let typeName: String + public let description: String + public let keywordTags: [String] + public let imageURL: String + + public init( + philosopherType: String, + philosopherLabel: String, + typeName: String, + description: String, + keywordTags: [String], + imageURL: String + ) { + self.philosopherType = philosopherType + self.philosopherLabel = philosopherLabel + self.typeName = typeName + self.description = description + self.keywordTags = keywordTags + self.imageURL = imageURL + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/RecapScoreAxis.swift b/Projects/Domain/Entity/Sources/Profile/Recap/RecapScoreAxis.swift new file mode 100644 index 00000000..40c7d876 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Recap/RecapScoreAxis.swift @@ -0,0 +1,19 @@ +// +// RecapScoreAxis.swift +// Entity +// + +import Foundation + +public struct RecapScoreAxis: Equatable, Identifiable { + public let label: String + /// 0~100. + public let value: Double + + public var id: String { label } + + public init(label: String, value: Double) { + self.label = label + self.value = value + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift b/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift new file mode 100644 index 00000000..072d744f --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift @@ -0,0 +1,45 @@ +// +// RecapScores.swift +// Entity +// +// 성향 분석 6축 점수 (0~100). +// + +import Foundation + +public struct RecapScores: Equatable { + public let principle: Double + public let reason: Double + public let individual: Double + public let change: Double + public let inner: Double + public let ideal: Double + + public init( + principle: Double, + reason: Double, + individual: Double, + change: Double, + inner: Double, + ideal: Double + ) { + self.principle = principle + self.reason = reason + self.individual = individual + self.change = change + self.inner = inner + self.ideal = ideal + } + + /// 레이더/바 표시 순서 (라벨, 0~100 값). + public var axes: [RecapScoreAxis] { + [ + RecapScoreAxis(label: "원칙", value: principle), + RecapScoreAxis(label: "이성", value: reason), + RecapScoreAxis(label: "개인", value: individual), + RecapScoreAxis(label: "변화", value: change), + RecapScoreAxis(label: "이상", value: ideal), + RecapScoreAxis(label: "내면", value: inner), + ] + } +} diff --git a/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift index da414a83..fd5c953e 100644 --- a/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift +++ b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift @@ -19,6 +19,10 @@ public struct ProfileUseCaseImpl: ProfileInterface { return try await profileRepository.fetchMyPage() } + public func fetchRecap() async throws -> PhilosopherRecap { + return try await profileRepository.fetchRecap() + } + public func fetchCreditHistory( offset: Int, size: Int diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift index b1568da2..b7b45513 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift @@ -87,9 +87,15 @@ extension ProfileCoordinator { case .routeAction(_, action: .pointHistory(.delegate(.dismiss))): return .send(.view(.backAction)) + // 나의 철학자 유형 카드 탭 → 리캡 화면 진입. case .routeAction(_, action: .profile(.delegate(.openPhilosopher))): + state.routes.push(.recap(.init())) return .none + // 리캡 백탭 → 뒤로. + case .routeAction(_, action: .recap(.delegate(.dismiss))): + return .send(.view(.backAction)) + // 설정 아이콘 → 설정 화면 진입. case .routeAction(_, action: .profile(.delegate(.openSettings))): state.routes.push(.settings(.init())) @@ -182,6 +188,7 @@ extension ProfileCoordinator { case notice(NoticeFeature) case notificationSetting(NotificationSettingFeature) case battleProposal(BattleProposalFeature) + case recap(RecapFeature) case web(WebReducer) } } diff --git a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift index 56f86417..6a75b8ad 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift @@ -37,6 +37,8 @@ public struct ProfileCoordinatorView: View { NotificationSettingView(store: notificationStore) case let .battleProposal(battleProposalStore): BattleProposalView(store: battleProposalStore) + case let .recap(recapStore): + RecapView(store: recapStore) case let .web(webStore): WebView(store: webStore) } diff --git a/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift b/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift new file mode 100644 index 00000000..e9b1c68f --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift @@ -0,0 +1,150 @@ +// +// RecapFeature.swift +// Profile +// +// 나의 철학자 유형(리캡) — picke.pen `나의 철학자 유형`. +// GET /api/v1/me/recap 로드 + 공유. +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro +import UseCase + +@Reducer +public struct RecapFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var isLoading: Bool = false + public var recap: PhilosopherRecap? + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backTapped + case shareTapped + } + + public enum AsyncAction: Equatable { + case fetch + } + + public enum InnerAction: Equatable { + case recapResponse(Result) + } + + public enum DelegateAction: Equatable { + case dismiss + case share(PhilosopherRecap) + } + + nonisolated enum CancelID: Hashable { + case fetch + } + + @Dependency(\.profileUseCase) private var profileUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension RecapFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard state.recap == nil else { return .none } + return .send(.async(.fetch)) + + case .backTapped: + return .send(.delegate(.dismiss)) + + case .shareTapped: + guard let recap = state.recap else { return .none } + return .send(.delegate(.share(recap))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetch: + state.isLoading = true + return .run { [useCase = profileUseCase] send in + let result = await Result { + try await useCase.fetchRecap() + } + .mapError(ProfileError.from) + return await send(.inner(.recapResponse(result))) + } + .cancellable(id: CancelID.fetch, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .recapResponse(result): + state.isLoading = false + switch result { + case let .success(recap): + state.recap = recap + case let .failure(error): + Log.error("[RecapFeature] fetchRecap failed: \(error.localizedDescription)") + } + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + case .share: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapMatchCard.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapMatchCard.swift new file mode 100644 index 00000000..0bd9a7d1 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapMatchCard.swift @@ -0,0 +1,76 @@ +// +// RecapMatchCard.swift +// Profile +// +// 궁합 유형 카드 (BEST / WORST) — 아바타 + 유형명 + 설명. +// (잠금 화면에서도 재사용) +// + +import SwiftUI + +import DesignSystem +import Entity +import Kingfisher + +public struct RecapMatchCard: View { + private let card: RecapCard + private let isBest: Bool + + public init(card: RecapCard, isBest: Bool) { + self.card = card + self.isBest = isBest + } + + public var body: some View { + VStack(spacing: 8) { + HStack(spacing: 4) { + Image(systemName: isBest ? "hand.thumbsup" : "hand.thumbsdown") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(isBest ? .secondary500 : .gray400) + Text(isBest ? "BEST" : "WORST") + .pretendardFont(family: .Bold, size: 10) + .foregroundStyle(isBest ? .secondary500 : .gray400) + } + + avatar + + VStack(spacing: 6) { + Text(card.typeName) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.gray500) + + Text(card.description) + .pretendardFont(family: .Regular, size: 11) + .foregroundStyle(.gray300) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + } + .padding(16) + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + + @ViewBuilder + private var avatar: some View { + ZStack { + Circle().fill(.beige600) + if !card.imageURL.isEmpty, let url = URL(string: card.imageURL) { + KFImage(url) + .resizable() + .scaledToFit() + .padding(4) + } else { + Image(systemName: "brain.head.profile") + .font(.system(size: 18)) + .foregroundStyle(.gray300) + } + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } +} diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapPhilosopherCard.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapPhilosopherCard.swift new file mode 100644 index 00000000..f40e895b --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapPhilosopherCard.swift @@ -0,0 +1,95 @@ +// +// RecapPhilosopherCard.swift +// Profile +// +// 내 철학자 유형 카드 — 상단 액센트 라인 + 유형명 + 아바타 + 설명 + 키워드 뱃지. +// (잠금 화면에서도 재사용) +// + +import SwiftUI + +import DesignSystem +import Entity +import Kingfisher + +public struct RecapPhilosopherCard: View { + private let card: RecapCard + + public init(card: RecapCard) { + self.card = card + } + + public var body: some View { + VStack(spacing: 24) { + VStack(spacing: 6) { + Text("나의 철학자 유형") + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.primary500) + + Text(card.typeName) + .pretendardFont(family: .SemiBold, size: 24) + .foregroundStyle(.gray500) + } + + avatar + + VStack(spacing: 32) { + Text(card.description) + .pretendardFont(family: .Regular, size: 14) + .foregroundStyle(.gray400) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + if !card.keywordTags.isEmpty { + HStack(spacing: 8) { + ForEach(card.keywordTags, id: \.self) { tag in + Text(tag) + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.primary500) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.primary100, lineWidth: 1) + ) + } + } + } + } + } + .padding(.vertical, 16) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + .overlay(alignment: .top) { + Rectangle() + .fill(.primary500) + .frame(height: 3) + } + .clipShape(RoundedRectangle(cornerRadius: 2)) + } + + @ViewBuilder + private var avatar: some View { + ZStack { + Circle().fill(.beige600) + if !card.imageURL.isEmpty, let url = URL(string: card.imageURL) { + KFImage(url) + .resizable() + .scaledToFit() + .padding(6) + } else { + Image(systemName: "brain.head.profile") + .font(.system(size: 30)) + .foregroundStyle(.gray300) + } + } + .frame(width: 68, height: 68) + .clipShape(Circle()) + } +} diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift new file mode 100644 index 00000000..929d80ca --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift @@ -0,0 +1,107 @@ +// +// RecapRadarChart.swift +// Profile +// +// 성향 분석 6축 레이더(육각) 차트 — 데이터 폴리곤이 중심에서 펼쳐지는 애니메이션. +// + +import SwiftUI + +import DesignSystem +import Entity + +public struct RecapRadarChart: View { + private let axes: [RecapScoreAxis] + private let rings: Int = 4 + + @State private var progress: CGFloat = 0 + + public init(axes: [RecapScoreAxis]) { + self.axes = axes + } + + public var body: some View { + GeometryReader { geo in + let side = min(geo.size.width, geo.size.height) + let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2) + let radius = side / 2 * 0.72 + + ZStack { + // 배경 그리드 (동심 육각형) + ForEach(1 ... rings, id: \.self) { ring in + polygon(center: center, radius: radius * CGFloat(ring) / CGFloat(rings)) + .stroke(.beige600, lineWidth: 1) + } + + // 축 스포크 + ForEach(Array(axes.enumerated()), id: \.offset) { index, _ in + Path { path in + path.move(to: center) + path.addLine(to: vertex(center: center, radius: radius, index: index)) + } + .stroke(.beige600, lineWidth: 1) + } + + // 데이터 폴리곤 (애니메이션) + dataPolygon(center: center, radius: radius) + .fill(.primary500.opacity(0.22)) + dataPolygon(center: center, radius: radius) + .stroke(.primary500, lineWidth: 1.5) + + // 축 라벨 + ForEach(Array(axes.enumerated()), id: \.offset) { index, axis in + Text(axis.label) + .pretendardFont(family: .Medium, size: 10) + .foregroundStyle(.gray500) + .position(labelPosition(center: center, radius: radius, index: index)) + } + } + .onAppear { + progress = 0 + withAnimation(.easeOut(duration: 0.8)) { progress = 1 } + } + } + } +} + +private extension RecapRadarChart { + /// index 축의 각도(라디안). 12시 방향에서 시작해 시계 방향. + func angle(for index: Int) -> CGFloat { + let step = (2 * CGFloat.pi) / CGFloat(axes.count) + return -CGFloat.pi / 2 + step * CGFloat(index) + } + + func vertex(center: CGPoint, radius: CGFloat, index: Int) -> CGPoint { + let a = angle(for: index) + return CGPoint(x: center.x + radius * cos(a), y: center.y + radius * sin(a)) + } + + func labelPosition(center: CGPoint, radius: CGFloat, index: Int) -> CGPoint { + let a = angle(for: index) + let r = radius + 14 + return CGPoint(x: center.x + r * cos(a), y: center.y + r * sin(a)) + } + + /// 동심 육각형. + func polygon(center: CGPoint, radius: CGFloat) -> Path { + Path { path in + for index in axes.indices { + let p = vertex(center: center, radius: radius, index: index) + if index == 0 { path.move(to: p) } else { path.addLine(to: p) } + } + path.closeSubpath() + } + } + + /// 점수 기반 데이터 폴리곤 (progress 로 중심→값 보간). + func dataPolygon(center: CGPoint, radius: CGFloat) -> Path { + Path { path in + for (index, axis) in axes.enumerated() { + let ratio = max(0, min(1, axis.value / 100)) * progress + let p = vertex(center: center, radius: radius * ratio, index: index) + if index == 0 { path.move(to: p) } else { path.addLine(to: p) } + } + path.closeSubpath() + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapScoreBar.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapScoreBar.swift new file mode 100644 index 00000000..f14c960c --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapScoreBar.swift @@ -0,0 +1,58 @@ +// +// RecapScoreBar.swift +// Profile +// +// 성향 6축 점수 미니 바 — 채워지는 애니메이션. +// + +import SwiftUI + +import DesignSystem +import Entity + +public struct RecapScoreBar: View { + private let axis: RecapScoreAxis + private let trackWidth: CGFloat = 48 + + @State private var progress: CGFloat = 0 + + public init(axis: RecapScoreAxis) { + self.axis = axis + } + + public var body: some View { + HStack(spacing: 10) { + Text(axis.label) + .pretendardFont(family: .Medium, size: 10) + .foregroundStyle(.neutral900) + + Spacer(minLength: 6) + + HStack(spacing: 6) { + ZStack(alignment: .leading) { + Capsule() + .fill(.primary100) + .frame(width: trackWidth, height: 4) + Capsule() + .fill(.primary500) + .frame(width: trackWidth * ratio * progress, height: 4) + } + + Text("\(Int(axis.value.rounded()))") + .pretendardFont(family: .SemiBold, size: 11) + .foregroundStyle(.neutral900) + } + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(.beige400, in: RoundedRectangle(cornerRadius: 2)) + .onAppear { + progress = 0 + withAnimation(.easeOut(duration: 0.7)) { progress = 1 } + } + } + + private var ratio: CGFloat { + max(0, min(1, axis.value / 100)) + } +} diff --git a/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift b/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift new file mode 100644 index 00000000..db26fc8e --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift @@ -0,0 +1,214 @@ +// +// RecapView.swift +// Profile +// +// 나의 철학자 유형(리캡) UI — picke.pen `나의 철학자 유형`. +// 내 카드 + 성향 분석(레이더/바) + 내 취향 리포트 + 궁합 유형 + 공유하기. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +@ViewAction(for: RecapFeature.self) +public struct RecapView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "나의 철학자 유형") { + Button { send(.shareTapped) } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(.neutral900) + .frame(width: 24, height: 24) + } + } + .foregroundStyle(.gray500) + + if let recap = store.recap { + content(recap) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + } +} + +private extension RecapView { + @ViewBuilder + func content(_ recap: PhilosopherRecap) -> some View { + ScrollView { + VStack(spacing: 24) { + RecapPhilosopherCard(card: recap.myCard) + tendencySection(recap.scores) + reportSection(recap.preferenceReport) + matchSection(recap) + shareButton() + } + .padding(.top, 20) + .padding(.horizontal, 16) + .padding(.bottom, 32) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + } + + // MARK: 공통 섹션 (헤딩 + 카드) + + @ViewBuilder + func sectionHeading(_ title: String) -> some View { + Text(title) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.gray800) + .frame(maxWidth: .infinity, alignment: .center) + } + + @ViewBuilder + func card(@ViewBuilder _ content: () -> some View) -> some View { + content() + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + + // MARK: 성향 분석 + + @ViewBuilder + func tendencySection(_ scores: RecapScores) -> some View { + VStack(spacing: 12) { + sectionHeading("성향 분석") + card { + VStack(spacing: 8) { + RecapRadarChart(axes: scores.axes) + .frame(height: 172) + + LazyVGrid( + columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)], + spacing: 8 + ) { + ForEach(scores.axes) { axis in + RecapScoreBar(axis: axis) + } + } + } + .padding(.vertical, 16) + .padding(.horizontal, 20) + } + } + } + + // MARK: 내 취향 리포트 + + @ViewBuilder + func reportSection(_ report: PreferenceReport) -> some View { + VStack(spacing: 12) { + sectionHeading("내 취향 리포트") + card { + VStack(spacing: 20) { + HStack(spacing: 0) { + statCell(value: "\(report.totalParticipation)", label: "총 참여", divider: true) + statCell(value: "\(report.opinionChanges)", label: "의견 전환", divider: true) + statCell(value: "\(report.battleWinRate)%", label: "배틀 승률", divider: false) + } + .padding(.horizontal, 16) + + VStack(spacing: 0) { + ForEach(report.favoriteTopics) { topic in + topicRow(topic) + } + } + } + .padding(.top, 16) + } + } + } + + @ViewBuilder + func statCell(value: String, label: String, divider: Bool) -> some View { + VStack(spacing: 0) { + Text(value) + .pretendardFont(family: .Bold, size: 16) + .foregroundStyle(.gray800) + Text(label) + .pretendardFont(family: .Medium, size: 10) + .foregroundStyle(.gray300) + } + .frame(maxWidth: .infinity) + .overlay(alignment: .trailing) { + if divider { + Rectangle().fill(.beige600).frame(width: 1, height: 28) + } + } + } + + @ViewBuilder + func topicRow(_ topic: FavoriteTopic) -> some View { + HStack(spacing: 6) { + Text(String(format: "%02d", topic.rank)) + .pretendardFont(family: .Bold, size: 10) + .foregroundStyle(.secondary500) + Text(topic.tagText) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray800) + Spacer(minLength: 6) + Text("\(topic.participationCount)회") + .pretendardFont(family: .Medium, size: 10) + .foregroundStyle(.gray300) + } + .padding(.vertical, 12) + .padding(.horizontal, 16) + .overlay(alignment: .top) { + Rectangle().fill(.beige600).frame(height: 1) + } + } + + // MARK: 궁합 유형 + + @ViewBuilder + func matchSection(_ recap: PhilosopherRecap) -> some View { + VStack(spacing: 12) { + sectionHeading("궁합 유형") + HStack(spacing: 8) { + RecapMatchCard(card: recap.bestMatchCard, isBest: true) + RecapMatchCard(card: recap.worstMatchCard, isBest: false) + } + } + } + + // MARK: 공유하기 + + @ViewBuilder + func shareButton() -> some View { + Button { + send(.shareTapped) + } label: { + HStack(spacing: 6) { + Text("공유하기") + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.beige50) + Image(systemName: "square.and.arrow.up") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.beige50) + } + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(.primary500, in: RoundedRectangle(cornerRadius: 2)) + } + .buttonStyle(.plain) + } +} From e466bc3221dda7238823a79ff53feeb3ae657d88 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 11:18:13 +0900 Subject: [PATCH 15/39] =?UTF-8?q?feat:=20=EB=A6=AC=EC=BA=A1=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B3=B5=EC=9C=A0(ShareSheet)=20+=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4=20+=20=EC=84=B1=ED=96=A5?= =?UTF-8?q?=20=EC=A0=90=EC=88=98=20=EB=B0=94=20=EA=B0=92=20=EA=B0=80?= =?UTF-8?q?=EB=8F=85=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공유하기 → 애플 시스템 공유 시트(ShareItem + ShareSheet, 유형명/설명/태그/이미지) - RecapSkeletonView 추가 — 로딩 시 ProgressView 대신 섹션 스켈레톤 - 성향 점수 바: 값 숫자 잘림 수정(fixedSize) + picke.pen Radar Wrap 스펙(바 48, 패딩 [8,12]) 정합 #13 --- .../Reducer/ProfileCoordinator.swift | 2 +- .../Sources/Recap/Reducer/RecapFeature.swift | 16 ++- .../Recap/View/Components/RecapScoreBar.swift | 37 +++--- .../View/Components/RecapSkeletonView.swift | 107 ++++++++++++++++++ .../Sources/Recap/View/RecapView.swift | 7 +- 5 files changed, 148 insertions(+), 21 deletions(-) create mode 100644 Projects/Presentation/Profile/Sources/Recap/View/Components/RecapSkeletonView.swift diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift index b7b45513..ec0d58b3 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift @@ -96,7 +96,7 @@ extension ProfileCoordinator { case .routeAction(_, action: .recap(.delegate(.dismiss))): return .send(.view(.backAction)) - // 설정 아이콘 → 설정 화면 진입. + // 설정 아이콘 → 설정 화면 진입.리고 case .routeAction(_, action: .profile(.delegate(.openSettings))): state.routes.push(.settings(.init())) return .none diff --git a/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift b/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift index e9b1c68f..63dc7ba6 100644 --- a/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift +++ b/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift @@ -21,6 +21,8 @@ public struct RecapFeature { public struct State: Equatable { public var isLoading: Bool = false public var recap: PhilosopherRecap? + /// 애플 시스템 공유 시트 트리거. + public var shareItem: ShareItem? public init() {} } @@ -97,7 +99,19 @@ extension RecapFeature { case .shareTapped: guard let recap = state.recap else { return .none } - return .send(.delegate(.share(recap))) + let text = [ + "나의 철학자 유형: \(recap.myCard.typeName)", + recap.myCard.description, + recap.myCard.keywordTags.map { "#\($0)" }.joined(separator: " "), + ] + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + var items: [Any] = [text] + if !recap.myCard.imageURL.isEmpty, let url = URL(string: recap.myCard.imageURL) { + items.append(url) + } + state.shareItem = ShareItem(items: items) + return .none } } diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapScoreBar.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapScoreBar.swift index f14c960c..b7f2e113 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapScoreBar.swift +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapScoreBar.swift @@ -21,30 +21,33 @@ public struct RecapScoreBar: View { } public var body: some View { - HStack(spacing: 10) { + HStack(spacing: 8) { Text(axis.label) .pretendardFont(family: .Medium, size: 10) .foregroundStyle(.neutral900) + .fixedSize() - Spacer(minLength: 6) - - HStack(spacing: 6) { - ZStack(alignment: .leading) { - Capsule() - .fill(.primary100) - .frame(width: trackWidth, height: 4) - Capsule() - .fill(.primary500) - .frame(width: trackWidth * ratio * progress, height: 4) - } - - Text("\(Int(axis.value.rounded()))") - .pretendardFont(family: .SemiBold, size: 11) - .foregroundStyle(.neutral900) + Spacer(minLength: 4) + + ZStack(alignment: .leading) { + Capsule() + .fill(.primary100) + .frame(width: trackWidth, height: 4) + Capsule() + .fill(.primary500) + .frame(width: trackWidth * ratio * progress, height: 4) } + .frame(width: trackWidth) + + Text("\(Int(axis.value.rounded()))") + .pretendardFont(family: .SemiBold, size: 11) + .foregroundStyle(.neutral900) + .fixedSize() + .frame(minWidth: 18, alignment: .trailing) } .padding(.vertical, 8) - .padding(.horizontal, 12) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity) .background(.beige400, in: RoundedRectangle(cornerRadius: 2)) .onAppear { progress = 0 diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapSkeletonView.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapSkeletonView.swift new file mode 100644 index 00000000..741d4777 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapSkeletonView.swift @@ -0,0 +1,107 @@ +// +// RecapSkeletonView.swift +// Profile +// +// 나의 철학자 유형(리캡) 로딩 스켈레톤 — 공통 SkeletonBlock(light). +// + +import SwiftUI + +import DesignSystem + +struct RecapSkeletonView: View { + var body: some View { + ScrollView { + VStack(spacing: 24) { + // 내 카드 + cardBox { + VStack(spacing: 16) { + block(width: 100, height: 13) + block(width: 120, height: 24) + SkeletonBlock(cornerRadius: 34, tone: .light).frame(width: 68, height: 68) + block(width: nil, maxWidth: true, height: 40) + HStack(spacing: 8) { + ForEach(0 ..< 3, id: \.self) { _ in block(width: 56, height: 20) } + } + } + .padding(20) + } + + // 성향 분석 + VStack(spacing: 12) { + block(width: 70, height: 13) + cardBox { + VStack(spacing: 12) { + SkeletonBlock(cornerRadius: 8, tone: .light).frame(height: 160) + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { + ForEach(0 ..< 6, id: \.self) { _ in block(width: nil, maxWidth: true, height: 28) } + } + } + .padding(.vertical, 16) + .padding(.horizontal, 20) + } + } + + // 취향 리포트 + VStack(spacing: 12) { + block(width: 90, height: 13) + cardBox { + VStack(spacing: 16) { + HStack(spacing: 24) { + ForEach(0 ..< 3, id: \.self) { _ in + VStack(spacing: 6) { block(width: 30, height: 16); block(width: 44, height: 10) } + .frame(maxWidth: .infinity) + } + } + ForEach(0 ..< 4, id: \.self) { _ in block(width: nil, maxWidth: true, height: 16) } + } + .padding(16) + } + } + + // 궁합 + VStack(spacing: 12) { + block(width: 60, height: 13) + HStack(spacing: 8) { + ForEach(0 ..< 2, id: \.self) { _ in + cardBox { + VStack(spacing: 8) { + block(width: 40, height: 12) + SkeletonBlock(cornerRadius: 20, tone: .light).frame(width: 40, height: 40) + block(width: 60, height: 13) + block(width: nil, maxWidth: true, height: 22) + } + .padding(16) + } + } + } + } + + SkeletonBlock(cornerRadius: 2, tone: .light).frame(height: 52) + } + .padding(.top, 20) + .padding(.horizontal, 16) + .padding(.bottom, 32) + } + .scrollIndicators(.hidden) + } + + @ViewBuilder + private func cardBox(@ViewBuilder _ content: () -> some View) -> some View { + content() + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + + @ViewBuilder + private func block(width: CGFloat?, maxWidth: Bool = false, height: CGFloat) -> some View { + SkeletonBlock(cornerRadius: 4, tone: .light) + .frame(width: width) + .frame(maxWidth: maxWidth ? .infinity : nil) + .frame(height: height) + } +} diff --git a/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift b/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift index db26fc8e..dad45eb9 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift +++ b/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift @@ -35,13 +35,16 @@ public struct RecapView: View { if let recap = store.recap { content(recap) } else { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) + RecapSkeletonView() } } .background(Color.beige200.ignoresSafeArea()) .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) + .sheet(item: $store.shareItem) { item in + ShareSheet(items: item.items) + .presentationDetents([.fraction(0.5)]) + } .onAppear { send(.onAppear) } } } From f9d60059dff129b2f83ec3da3e3968a88eb81d70 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 11:20:54 +0900 Subject: [PATCH 16/39] =?UTF-8?q?fix:=20=EB=A6=AC=EC=BA=A1=20=EC=84=B1?= =?UTF-8?q?=ED=96=A5=20=EC=A0=90=EC=88=98=20=EB=B0=94=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=20=EC=88=9C=EC=84=9C=20picke.pen=20=EC=A0=95=ED=95=A9?= =?UTF-8?q?=20(=EC=9B=90=EC=B9=99=C2=B7=EC=9D=B4=EC=84=B1=20/=20=EA=B0=9C?= =?UTF-8?q?=EC=9D=B8=C2=B7=EB=B3=80=ED=99=94=20/=20=EB=82=B4=EB=A9=B4?= =?UTF-8?q?=C2=B7=EC=9D=B4=EC=83=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecapScores.gridAxes 추가 — 바 2열 그리드 순서를 디자인대로(레이더 각도 순서와 분리) #13 --- .../Entity/Sources/Profile/Recap/RecapScores.swift | 14 +++++++++++++- .../Profile/Sources/Recap/View/RecapView.swift | 3 ++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift b/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift index 072d744f..3b574815 100644 --- a/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift +++ b/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift @@ -31,7 +31,7 @@ public struct RecapScores: Equatable { self.ideal = ideal } - /// 레이더/바 표시 순서 (라벨, 0~100 값). + /// 레이더 각도 순서 (원칙↑ → 시계방향: 이성·개인·변화·이상·내면). public var axes: [RecapScoreAxis] { [ RecapScoreAxis(label: "원칙", value: principle), @@ -42,4 +42,16 @@ public struct RecapScores: Equatable { RecapScoreAxis(label: "내면", value: inner), ] } + + /// 점수 바 2열 그리드 순서 (picke.pen: 원칙·이성 / 개인·변화 / 내면·이상). + public var gridAxes: [RecapScoreAxis] { + [ + RecapScoreAxis(label: "원칙", value: principle), + RecapScoreAxis(label: "이성", value: reason), + RecapScoreAxis(label: "개인", value: individual), + RecapScoreAxis(label: "변화", value: change), + RecapScoreAxis(label: "내면", value: inner), + RecapScoreAxis(label: "이상", value: ideal), + ] + } } diff --git a/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift b/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift index dad45eb9..ecf4081a 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift +++ b/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift @@ -104,7 +104,8 @@ private extension RecapView { columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)], spacing: 8 ) { - ForEach(scores.axes) { axis in + // picke.pen 그리드 순서: 원칙·이성 / 개인·변화 / 내면·이상 + ForEach(scores.gridAxes) { axis in RecapScoreBar(axis: axis) } } From e11a39cec48bda200012cde3f5eec262d050add0 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 12:30:53 +0900 Subject: [PATCH 17/39] =?UTF-8?q?fix:=20=EB=A6=AC=EC=BA=A1=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EB=8D=94=20=EC=B0=A8=ED=8A=B8=20picke.pen=20=EC=A0=95?= =?UTF-8?q?=ED=95=A9=20=E2=80=94=20=EC=B6=95=20=EC=88=9C=EC=84=9C/?= =?UTF-8?q?=EB=9D=BC=EB=B2=A8=20+=20=EA=BC=AD=EC=A7=93=EC=A0=90=20?= =?UTF-8?q?=EC=A0=90=20+=20=EC=83=81=EB=8B=A8=20=EB=B3=BC=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 레이더 축: 원칙(위)·이성·개인·변화·내면·직관 (디자인 라벨/순서) - 데이터 꼭짓점에 점(dot) 표시, 상단 '원칙' SemiBold + 라벨 12pt - 레이더는 axes(직관), 점수 바 그리드는 gridAxes(이상)로 각 디자인 분리 #13 --- .../Sources/Profile/Recap/RecapScores.swift | 4 ++-- .../Recap/View/Components/RecapRadarChart.swift | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift b/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift index 3b574815..fe5c6ddf 100644 --- a/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift +++ b/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift @@ -31,15 +31,15 @@ public struct RecapScores: Equatable { self.ideal = ideal } - /// 레이더 각도 순서 (원칙↑ → 시계방향: 이성·개인·변화·이상·내면). + /// 레이더 각도 순서 (원칙↑ → 시계방향: 이성·개인·변화·내면·직관). public var axes: [RecapScoreAxis] { [ RecapScoreAxis(label: "원칙", value: principle), RecapScoreAxis(label: "이성", value: reason), RecapScoreAxis(label: "개인", value: individual), RecapScoreAxis(label: "변화", value: change), - RecapScoreAxis(label: "이상", value: ideal), RecapScoreAxis(label: "내면", value: inner), + RecapScoreAxis(label: "직관", value: ideal), ] } diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift index 929d80ca..dfe39b63 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift @@ -44,15 +44,24 @@ public struct RecapRadarChart: View { // 데이터 폴리곤 (애니메이션) dataPolygon(center: center, radius: radius) - .fill(.primary500.opacity(0.22)) + .fill(.primary500.opacity(0.18)) dataPolygon(center: center, radius: radius) .stroke(.primary500, lineWidth: 1.5) - // 축 라벨 + // 데이터 꼭짓점 점 + ForEach(Array(axes.enumerated()), id: \.offset) { index, axis in + let ratio = max(0, min(1, axis.value / 100)) * progress + Circle() + .fill(.primary500) + .frame(width: 5, height: 5) + .position(vertex(center: center, radius: radius * ratio, index: index)) + } + + // 축 라벨 (상단 '원칙'은 볼드) ForEach(Array(axes.enumerated()), id: \.offset) { index, axis in Text(axis.label) - .pretendardFont(family: .Medium, size: 10) - .foregroundStyle(.gray500) + .pretendardFont(family: index == 0 ? .SemiBold : .Medium, size: 12) + .foregroundStyle(index == 0 ? .gray700 : .gray400) .position(labelPosition(center: center, radius: radius, index: index)) } } From df2493053a97c2cfbf3152b5123852ff3bde7224 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 12:33:42 +0900 Subject: [PATCH 18/39] =?UTF-8?q?fix:=20=EB=A6=AC=EC=BA=A1=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EB=8D=94=20=EC=B0=A8=ED=8A=B8=20picke.pen=20graph=20?= =?UTF-8?q?=EB=85=B8=EB=93=9C=EC=99=80=20=ED=94=BD=EC=85=80=20=EC=A0=95?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정규 육각형 → 디자인 육각형(폭/높이 0.846, 측면 꼭짓점 y±0.559) - 데이터 폴리곤 채움 primary500 8%(#89382514), 꼭짓점 점 6px, 4겹 그리드+스포크 - 라벨 10pt (원칙: SemiBold/neutral900, 나머지: Medium/gray400) - 중심→값 펼침 애니메이션(easeOut 0.9s) 유지 #13 --- .../View/Components/RecapRadarChart.swift | 74 +++++++++++-------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift index dfe39b63..024203f4 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift @@ -2,7 +2,8 @@ // RecapRadarChart.swift // Profile // -// 성향 분석 6축 레이더(육각) 차트 — 데이터 폴리곤이 중심에서 펼쳐지는 애니메이션. +// 성향 분석 6축 레이더 차트 — picke.pen `graph` 노드 정합. +// 세로로 약간 긴 육각형(폭/높이 = 0.846) + 4겹 그리드 + 스포크 + 데이터 폴리곤(중심→값 펼침 애니메이션) + 꼭짓점 점. // import SwiftUI @@ -14,6 +15,17 @@ public struct RecapRadarChart: View { private let axes: [RecapScoreAxis] private let rings: Int = 4 + /// picke.pen 육각형 단위 꼭짓점 (수직 반지름=1, 수평 0.846, 측면 y=±0.559). + /// 순서: 원칙↑ · 이성 · 개인 · 변화↓ · 내면 · 직관 (axes 순서와 동일). + private let unit: [CGPoint] = [ + CGPoint(x: 0, y: -1), + CGPoint(x: 0.846, y: -0.559), + CGPoint(x: 0.846, y: 0.559), + CGPoint(x: 0, y: 1), + CGPoint(x: -0.846, y: 0.559), + CGPoint(x: -0.846, y: -0.559), + ] + @State private var progress: CGFloat = 0 public init(axes: [RecapScoreAxis]) { @@ -22,19 +34,18 @@ public struct RecapRadarChart: View { public var body: some View { GeometryReader { geo in - let side = min(geo.size.width, geo.size.height) let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2) - let radius = side / 2 * 0.72 + let radius = min(geo.size.width, geo.size.height) / 2 * 0.78 ZStack { - // 배경 그리드 (동심 육각형) + // 4겹 그리드 육각형 ForEach(1 ... rings, id: \.self) { ring in - polygon(center: center, radius: radius * CGFloat(ring) / CGFloat(rings)) + hexPath(center: center, radius: radius * CGFloat(ring) / CGFloat(rings)) .stroke(.beige600, lineWidth: 1) } - // 축 스포크 - ForEach(Array(axes.enumerated()), id: \.offset) { index, _ in + // 스포크 + ForEach(unit.indices, id: \.self) { index in Path { path in path.move(to: center) path.addLine(to: vertex(center: center, radius: radius, index: index)) @@ -42,59 +53,58 @@ public struct RecapRadarChart: View { .stroke(.beige600, lineWidth: 1) } - // 데이터 폴리곤 (애니메이션) - dataPolygon(center: center, radius: radius) - .fill(.primary500.opacity(0.18)) - dataPolygon(center: center, radius: radius) + // 데이터 폴리곤 (primary500 8% 채움 + 스트로크, 애니메이션) + dataPath(center: center, radius: radius) + .fill(.primary500.opacity(0.08)) + dataPath(center: center, radius: radius) .stroke(.primary500, lineWidth: 1.5) - // 데이터 꼭짓점 점 + // 꼭짓점 점 (6px) ForEach(Array(axes.enumerated()), id: \.offset) { index, axis in - let ratio = max(0, min(1, axis.value / 100)) * progress + let ratio = clamp(axis.value / 100) * progress Circle() .fill(.primary500) - .frame(width: 5, height: 5) + .frame(width: 6, height: 6) .position(vertex(center: center, radius: radius * ratio, index: index)) } - // 축 라벨 (상단 '원칙'은 볼드) + // 축 라벨 (원칙: 600/neutral900, 나머지: 500/gray400) ForEach(Array(axes.enumerated()), id: \.offset) { index, axis in Text(axis.label) - .pretendardFont(family: index == 0 ? .SemiBold : .Medium, size: 12) - .foregroundStyle(index == 0 ? .gray700 : .gray400) + .pretendardFont(family: index == 0 ? .SemiBold : .Medium, size: 10) + .foregroundStyle(index == 0 ? .neutral900 : .gray400) + .fixedSize() .position(labelPosition(center: center, radius: radius, index: index)) } } .onAppear { progress = 0 - withAnimation(.easeOut(duration: 0.8)) { progress = 1 } + withAnimation(.easeOut(duration: 0.9)) { progress = 1 } } } } } private extension RecapRadarChart { - /// index 축의 각도(라디안). 12시 방향에서 시작해 시계 방향. - func angle(for index: Int) -> CGFloat { - let step = (2 * CGFloat.pi) / CGFloat(axes.count) - return -CGFloat.pi / 2 + step * CGFloat(index) + func clamp(_ value: Double) -> CGFloat { + CGFloat(max(0, min(1, value))) } func vertex(center: CGPoint, radius: CGFloat, index: Int) -> CGPoint { - let a = angle(for: index) - return CGPoint(x: center.x + radius * cos(a), y: center.y + radius * sin(a)) + let u = unit[index] + return CGPoint(x: center.x + radius * u.x, y: center.y + radius * u.y) } func labelPosition(center: CGPoint, radius: CGFloat, index: Int) -> CGPoint { - let a = angle(for: index) - let r = radius + 14 - return CGPoint(x: center.x + r * cos(a), y: center.y + r * sin(a)) + let u = unit[index] + let r = radius + 16 + return CGPoint(x: center.x + r * u.x, y: center.y + r * u.y) } - /// 동심 육각형. - func polygon(center: CGPoint, radius: CGFloat) -> Path { + /// 단위 꼭짓점 기반 육각형. + func hexPath(center: CGPoint, radius: CGFloat) -> Path { Path { path in - for index in axes.indices { + for index in unit.indices { let p = vertex(center: center, radius: radius, index: index) if index == 0 { path.move(to: p) } else { path.addLine(to: p) } } @@ -103,10 +113,10 @@ private extension RecapRadarChart { } /// 점수 기반 데이터 폴리곤 (progress 로 중심→값 보간). - func dataPolygon(center: CGPoint, radius: CGFloat) -> Path { + func dataPath(center: CGPoint, radius: CGFloat) -> Path { Path { path in for (index, axis) in axes.enumerated() { - let ratio = max(0, min(1, axis.value / 100)) * progress + let ratio = clamp(axis.value / 100) * progress let p = vertex(center: center, radius: radius * ratio, index: index) if index == 0 { path.move(to: p) } else { path.addLine(to: p) } } From 9e0111e45d63578b58039e1c14e41927796dfcf5 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 9 Jun 2026 12:36:44 +0900 Subject: [PATCH 19/39] =?UTF-8?q?fix:=20=EB=A6=AC=EC=BA=A1=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EB=8D=94=20=ED=8F=B4=EB=A6=AC=EA=B3=A4=20=ED=8E=BC?= =?UTF-8?q?=EC=B9=A8=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EB=8F=99=EC=9E=91=20(Shape=20animatableDa?= =?UTF-8?q?ta)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - body에서 Path 직접 그리던 방식은 withAnimation이 모양을 보간 못 해 즉시 완성됨 - RadarPolygon: Shape 로 분리하고 progress 를 animatableData 로 노출 → 중심에서 값까지 부드럽게 펼쳐짐 - 꼭짓점 점은 .position(progress) 로 함께 보간 #13 --- .../View/Components/RecapRadarChart.swift | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift index 024203f4..d24cda2f 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift @@ -36,6 +36,7 @@ public struct RecapRadarChart: View { GeometryReader { geo in let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2) let radius = min(geo.size.width, geo.size.height) / 2 * 0.78 + let ratios = axes.map { clamp($0.value / 100) } ZStack { // 4겹 그리드 육각형 @@ -53,10 +54,10 @@ public struct RecapRadarChart: View { .stroke(.beige600, lineWidth: 1) } - // 데이터 폴리곤 (primary500 8% 채움 + 스트로크, 애니메이션) - dataPath(center: center, radius: radius) + // 데이터 폴리곤 (animatableData 로 중심→값 실제 보간) + RadarPolygon(ratios: ratios, unit: unit, progress: progress) .fill(.primary500.opacity(0.08)) - dataPath(center: center, radius: radius) + RadarPolygon(ratios: ratios, unit: unit, progress: progress) .stroke(.primary500, lineWidth: 1.5) // 꼭짓점 점 (6px) @@ -111,16 +112,29 @@ private extension RecapRadarChart { path.closeSubpath() } } +} - /// 점수 기반 데이터 폴리곤 (progress 로 중심→값 보간). - func dataPath(center: CGPoint, radius: CGFloat) -> Path { - Path { path in - for (index, axis) in axes.enumerated() { - let ratio = clamp(axis.value / 100) * progress - let p = vertex(center: center, radius: radius * ratio, index: index) - if index == 0 { path.move(to: p) } else { path.addLine(to: p) } - } - path.closeSubpath() +/// 데이터 폴리곤 Shape — progress(animatableData)로 중심→값을 부드럽게 보간. +private struct RadarPolygon: Shape { + let ratios: [CGFloat] + let unit: [CGPoint] + var progress: CGFloat + + var animatableData: CGFloat { + get { progress } + set { progress = newValue } + } + + func path(in rect: CGRect) -> Path { + let center = CGPoint(x: rect.midX, y: rect.midY) + let radius = min(rect.width, rect.height) / 2 * 0.78 + var path = Path() + for index in unit.indices { + let r = radius * ratios[index] * progress + let point = CGPoint(x: center.x + r * unit[index].x, y: center.y + r * unit[index].y) + if index == 0 { path.move(to: point) } else { path.addLine(to: point) } } + path.closeSubpath() + return path } } From 6349713412b5b187b5e9a13609fde94e6afe1d1f Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 12:14:55 +0900 Subject: [PATCH 20/39] =?UTF-8?q?feat:=20=EB=82=98=EC=9D=98=20=EC=B2=A0?= =?UTF-8?q?=ED=95=99=EC=9E=90=20=EC=9C=A0=ED=98=95=20=EC=9E=A0=EA=B8=88=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20+=20=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B2=A0=ED=95=99=EC=9E=90=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88/=EB=B9=84=EC=9E=A0=EA=B8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - recap: totalParticipation < 5 → RecapLockedView(??형 카드 + 블러 성향 + '배틀 5개' 안내), 아니면 성향 표시 - 마이페이지 철학자 카드: 미확정 시 '??형'+그레이 placeholder, 확정 시 '유형명 · 라벨'+철학자 이미지 - ProfileFeature: philosopherLabel/imageURL/isPhilosopherLocked 추가, mypage 매핑 - 닉네임 옆 자물쇠 제거(picke.pen 노드에 없음), 미사용 isLocked 제거 - RecapLockedView 블러 4.375 등 잠금 화면 패딩 picke.pen 정합 #13 --- .../Project+Templete/Extension+String.swift | 2 +- .../Sources/Main/Reducer/ProfileFeature.swift | 22 +++- .../Sources/Main/View/ProfileView.swift | 30 ++--- .../Sources/Recap/Reducer/RecapFeature.swift | 9 ++ .../View/Components/RecapLockedView.swift | 110 ++++++++++++++++++ .../Sources/Recap/View/RecapView.swift | 6 +- 6 files changed, 158 insertions(+), 21 deletions(-) create mode 100644 Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift index 8b042781..8c182788 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift @@ -17,7 +17,7 @@ extension String { return Project.Environment.bundlePrefix } - public static func appBuildVersion(buildVersion: String = "19") -> String { + public static func appBuildVersion(buildVersion: String = "20") -> String { return buildVersion } diff --git a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift index 9adc10f7..88be1b1f 100644 --- a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift +++ b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift @@ -34,12 +34,14 @@ public struct ProfileFeature { public var nickname: String = "사색하는 고양이" /// 사용자 코드 (앞에 `@` 표기). public var userCode: String = "user_code" - /// 프로필 잠금 여부 — 닉네임 옆 자물쇠 노출. - public var isLocked: Bool = true /// 보유 포인트. public var point: Int = 240 - /// 나의 철학자 유형 — 미확정 시 `??형`. + /// 나의 철학자 유형명(예: `칸트형`) — 미확정 시 nil → `??형`. public var philosopherType: String? + /// 철학자 라벨(예: `원칙주의자`). + public var philosopherLabel: String = "" + /// 철학자 이미지 URL. + public var philosopherImageURL: String? /// 프로필 이미지 URL (없으면 기본 아바타). public var profileImageURL: String? /// 메뉴 목록. @@ -47,8 +49,16 @@ public struct ProfileFeature { public init() {} - /// 표시용 철학자 유형. - public var philosopherDisplay: String { philosopherType ?? "??형" } + /// 철학자 유형 미확정(잠금) 여부. + public var isPhilosopherLocked: Bool { + philosopherType?.isEmpty ?? true + } + + /// 표시용 철학자 유형 — 잠금 시 `??형`, 아니면 `유형명 · 라벨`. + public var philosopherDisplay: String { + guard let type = philosopherType, !type.isEmpty else { return "??형" } + return philosopherLabel.isEmpty ? type : "\(type) · \(philosopherLabel)" + } } public enum Action: ViewAction, BindableAction { @@ -188,6 +198,8 @@ extension ProfileFeature { state.userCode = myPage.profile.userTag state.point = myPage.tier.currentPoint state.philosopherType = myPage.philosopher.typeName.isEmpty ? nil : myPage.philosopher.typeName + state.philosopherLabel = myPage.philosopher.philosopherLabel + state.philosopherImageURL = myPage.philosopher.imageURL.isEmpty ? nil : myPage.philosopher.imageURL state.profileImageURL = myPage.profile.characterImageURL.isEmpty ? nil : myPage.profile.characterImageURL case let .failure(error): Log.error("[ProfileFeature] fetchMyPage failed: \(error.localizedDescription)") diff --git a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift index 5854b725..1488c10a 100644 --- a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift +++ b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift @@ -88,17 +88,9 @@ private extension ProfileView { avatar() VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 5) { - Text(store.nickname) - .pretendardFont(family: .SemiBold, size: 16) - .foregroundStyle(.gray800) - - if store.isLocked { - Image(systemName: "lock.fill") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.gray300) - } - } + Text(store.nickname) + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.gray800) Text("@\(store.userCode)") .pretendardFont(family: .Regular, size: 13) @@ -181,11 +173,21 @@ private extension ProfileView { ZStack { RoundedRectangle(cornerRadius: 8) .fill(.beige200) - Image(systemName: "brain.head.profile") - .font(.system(size: 20)) - .foregroundStyle(.gray300) + if let urlString = store.philosopherImageURL, let url = URL(string: urlString) { + KFImage(url) + .resizable() + .scaledToFit() + .padding(4) + } else { + Image(systemName: "brain.head.profile") + .font(.system(size: 20)) + .foregroundStyle(.gray300) + } } .frame(width: 40, height: 40) + // 잠금(??형) 시 철학자 아바타 그레이 처리 + .grayscale(store.isPhilosopherLocked ? 1 : 0) + .opacity(store.isPhilosopherLocked ? 0.7 : 1) VStack(alignment: .leading, spacing: 4) { Text("나의 철학자 유형") diff --git a/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift b/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift index 63dc7ba6..92f15e25 100644 --- a/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift +++ b/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift @@ -17,6 +17,9 @@ import UseCase public struct RecapFeature { public init() {} + /// 소비한 배틀이 이 수 미만이면 잠금. (서버 미제공 — 클라이언트 상수) + static let unlockThreshold = 5 + @ObservableState public struct State: Equatable { public var isLoading: Bool = false @@ -25,6 +28,12 @@ public struct RecapFeature { public var shareItem: ShareItem? public init() {} + + /// 소비한 배틀(총 참여) < 5 → 잠금. + public var isLocked: Bool { + guard let recap else { return false } + return recap.preferenceReport.totalParticipation < RecapFeature.unlockThreshold + } } public enum Action: ViewAction, BindableAction { diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift new file mode 100644 index 00000000..34b88cca --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift @@ -0,0 +1,110 @@ +// +// RecapLockedView.swift +// Profile +// +// 나의 철학자 유형 잠금 화면 — picke.pen `잠긴화면_콘텐츠 소비 5개 미만`. +// 분석 기록 부족 안내 카드 + 블러 처리된 성향 분석 + 잠금 해제 안내. +// + +import SwiftUI + +import DesignSystem +import Entity + +struct RecapLockedView: View { + /// 잠금 카드의 장식용 레이더(블러) 점수 — 실제 값 아님. + private let placeholderScores = RecapScores( + principle: 70, reason: 60, individual: 78, change: 45, inner: 65, ideal: 55 + ) + + var body: some View { + ScrollView { + VStack(spacing: 24) { + lockedCard + tendencyLockedSection + } + .padding(.top, 20) + .padding(.horizontal, 16) + .padding(.bottom, 32) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + } +} + +private extension RecapLockedView { + // MARK: 잠금 카드 (??형 + 안내) + + var lockedCard: some View { + VStack(spacing: 24) { + VStack(spacing: 6) { + Text("나의 철학자 유형") + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.primary500) + Text("??형") + .pretendardFont(family: .SemiBold, size: 24) + .foregroundStyle(.gray500) + } + + ZStack { + Circle().fill(.beige600) + Image(systemName: "brain.head.profile") + .font(.system(size: 30)) + .foregroundStyle(.gray300) + } + .frame(width: 68, height: 68) + .grayscale(1) + .opacity(0.7) + + Text("아직 분석할 기록이 부족해요.\n배틀에 참여하면 성향을 확인할 수 있어요!") + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.gray800) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + .padding(.vertical, 16) + .padding(.horizontal, 24) + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + .overlay(alignment: .top) { + Rectangle().fill(.primary500).frame(height: 3) + } + .clipShape(RoundedRectangle(cornerRadius: 2)) + } + + // MARK: 성향 분석 (블러 + 잠금 안내) + + var tendencyLockedSection: some View { + VStack(spacing: 12) { + Text("성향 분석") + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.gray800) + + ZStack { + RecapRadarChart(axes: placeholderScores.axes) + .frame(height: 172) + .opacity(0.4) + .blur(radius: 4.375) + .allowsHitTesting(false) + + Text("배틀 5개에 참여하시면\n잠금을 풀 수 있어요!") + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.gray800) + .multilineTextAlignment(.center) + } + .padding(.vertical, 16) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 2)) + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift b/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift index ecf4081a..e9f280e4 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift +++ b/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift @@ -33,7 +33,11 @@ public struct RecapView: View { .foregroundStyle(.gray500) if let recap = store.recap { - content(recap) + if store.isLocked { + RecapLockedView() + } else { + content(recap) + } } else { RecapSkeletonView() } From fb241d9518ed7f2e2ed686637ce7a275c0fa6497 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 12:15:55 +0900 Subject: [PATCH 21/39] =?UTF-8?q?refactor:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=8B=89=EB=84=A4=EC=9E=84/=EC=BD=94?= =?UTF-8?q?=EB=93=9C/=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=AA=A9=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=20=EC=A0=9C=EA=B1=B0=20=E2=80=94=20/me/mypag?= =?UTF-8?q?e=20=EC=9D=91=EB=8B=B5=EC=9C=BC=EB=A1=9C=EB=A7=8C=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #9 --- .../Sources/Main/Reducer/ProfileFeature.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift index 88be1b1f..6e492b52 100644 --- a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift +++ b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift @@ -30,12 +30,12 @@ public struct ProfileFeature { @ObservableState public struct State: Equatable { public var isLoading: Bool = false - /// 닉네임. - public var nickname: String = "사색하는 고양이" - /// 사용자 코드 (앞에 `@` 표기). - public var userCode: String = "user_code" - /// 보유 포인트. - public var point: Int = 240 + /// 닉네임 — /me/mypage 응답에서 주입. + public var nickname: String = "" + /// 사용자 코드 (앞에 `@` 표기) — /me/mypage 응답에서 주입. + public var userCode: String = "" + /// 보유 포인트 — /me/mypage 응답에서 주입. + public var point: Int = 0 /// 나의 철학자 유형명(예: `칸트형`) — 미확정 시 nil → `??형`. public var philosopherType: String? /// 철학자 라벨(예: `원칙주의자`). From 73a1f9a5dada0bc6e0e7a4d2603626bccdde8063 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 12:22:18 +0900 Subject: [PATCH 22/39] =?UTF-8?q?fix:=20Profile=20=ED=8C=A8=EB=94=A9=20pic?= =?UTF-8?q?ke.pen=20=EC=A0=95=ED=95=A9=20=E2=80=94=20=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=82=B4=EC=97=AD=20=EC=B9=B4=EB=93=9C=2016/?= =?UTF-8?q?=EA=B0=84=EA=B2=A9=2016,=20=EC=84=A4=EC=A0=95=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20top=2012?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 포인트 내역: 카드 padding 12→16, 리스트 간격 12→16 (pen yqKel gap16 / Alram Box padding16) - 포인트 내역 스켈레톤: padding 16 + scrollIndicators(.hidden) - 설정 메뉴: 상단 padding 12 추가 (pen y7ta5 [12,16]) - 배틀기록/콘텐츠활동/공지/알림설정/철학자/잠금은 net 패딩 이미 pen 일치 확인 #9 #10 #15 --- .../View/Components/PointHistorySkeletonView.swift | 5 +++-- .../Profile/Sources/PointHistory/View/PointHistoryView.swift | 4 ++-- .../Profile/Sources/Settings/View/SettingsView.swift | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift b/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift index d4c365d8..f9ffe4d2 100644 --- a/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift +++ b/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift @@ -11,7 +11,7 @@ import DesignSystem struct PointHistorySkeletonView: View { var body: some View { - ScrollView(showsIndicators: false) { + ScrollView { VStack(spacing: 16) { ForEach(0 ..< 8, id: \.self) { _ in HStack(spacing: 16) { @@ -25,7 +25,7 @@ struct PointHistorySkeletonView: View { block(width: 28, height: 12) } } - .padding(12) + .padding(16) .frame(maxWidth: .infinity) .background(.beige50, in: RoundedRectangle(cornerRadius: 8)) .overlay( @@ -37,6 +37,7 @@ struct PointHistorySkeletonView: View { .padding(.vertical, 20) .padding(.horizontal, 16) } + .scrollIndicators(.hidden) } @ViewBuilder diff --git a/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift b/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift index d7c6bdda..72beec42 100644 --- a/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift +++ b/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift @@ -64,7 +64,7 @@ private extension PointHistoryView { emptyState() } else { ScrollView { - LazyVStack(spacing: 12) { + LazyVStack(spacing: 16) { ForEach(store.items) { item in historyRow(item) .onAppear { @@ -112,7 +112,7 @@ private extension PointHistoryView { .foregroundStyle(.gray300) } } - .padding(12) + .padding(16) .frame(maxWidth: .infinity) .background(.beige50, in: RoundedRectangle(cornerRadius: 8)) .overlay( diff --git a/Projects/Presentation/Profile/Sources/Settings/View/SettingsView.swift b/Projects/Presentation/Profile/Sources/Settings/View/SettingsView.swift index d60fb47d..97048d03 100644 --- a/Projects/Presentation/Profile/Sources/Settings/View/SettingsView.swift +++ b/Projects/Presentation/Profile/Sources/Settings/View/SettingsView.swift @@ -50,6 +50,7 @@ private extension SettingsView { menuRow(item) } } + .padding(.top, 12) .padding(.horizontal, 16) } From e2849f5417f45a12285e8e14f47771e9a0d8fe9b Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 19:08:14 +0900 Subject: [PATCH 23/39] =?UTF-8?q?fix:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=94=94=EC=BD=94=EB=94=A9=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=E2=80=94=20philosopher=20null=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /me/mypage 의 philosopher 가 미확정 시 null 로 내려와 디코딩 에러(DataError) 발생 - MyPageDataDTO 의 profile/philosopher/tier 를 옵셔널로 + 매퍼 nil→.empty 폴백 - MyProfile/MyPhilosopher/MyTier 에 .empty 정적 헬퍼 추가 - philosopher null → ??형 잠금 정상 표시 #9 --- .../Data/Model/Sources/Profile/DTO/MyPageDataDTO.swift | 7 ++++--- .../Model/Sources/Profile/Mapper/MyPageDataDTO+.swift | 6 +++--- .../Entity/Sources/Profile/MyPage/MyPhilosopher.swift | 8 ++++++++ .../Domain/Entity/Sources/Profile/MyPage/MyProfile.swift | 9 +++++++++ .../Domain/Entity/Sources/Profile/MyPage/MyTier.swift | 2 ++ 5 files changed, 26 insertions(+), 6 deletions(-) diff --git a/Projects/Data/Model/Sources/Profile/DTO/MyPageDataDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/MyPageDataDTO.swift index a597c5de..927754a9 100644 --- a/Projects/Data/Model/Sources/Profile/DTO/MyPageDataDTO.swift +++ b/Projects/Data/Model/Sources/Profile/DTO/MyPageDataDTO.swift @@ -8,9 +8,10 @@ import Foundation public struct MyPageDataDTO: Decodable { - public let profile: MyProfileDTO - public let philosopher: MyPhilosopherDTO - public let tier: MyTierDTO + // philosopher 는 미확정(배틀 5개 미만) 시 null 로 내려와 옵셔널로 둔다. + public let profile: MyProfileDTO? + public let philosopher: MyPhilosopherDTO? + public let tier: MyTierDTO? } public struct MyProfileDTO: Decodable { diff --git a/Projects/Data/Model/Sources/Profile/Mapper/MyPageDataDTO+.swift b/Projects/Data/Model/Sources/Profile/Mapper/MyPageDataDTO+.swift index 93d9e513..30751898 100644 --- a/Projects/Data/Model/Sources/Profile/Mapper/MyPageDataDTO+.swift +++ b/Projects/Data/Model/Sources/Profile/Mapper/MyPageDataDTO+.swift @@ -9,9 +9,9 @@ import Foundation public extension MyPageDataDTO { func toDomain() -> MyPage { MyPage( - profile: profile.toDomain(), - philosopher: philosopher.toDomain(), - tier: tier.toDomain() + profile: profile?.toDomain() ?? .empty, + philosopher: philosopher?.toDomain() ?? .empty, + tier: tier?.toDomain() ?? .empty ) } } diff --git a/Projects/Domain/Entity/Sources/Profile/MyPage/MyPhilosopher.swift b/Projects/Domain/Entity/Sources/Profile/MyPage/MyPhilosopher.swift index d0db03e8..2f53f06a 100644 --- a/Projects/Domain/Entity/Sources/Profile/MyPage/MyPhilosopher.swift +++ b/Projects/Domain/Entity/Sources/Profile/MyPage/MyPhilosopher.swift @@ -27,4 +27,12 @@ public struct MyPhilosopher: Equatable { self.description = description self.imageURL = imageURL } + + public static let empty = MyPhilosopher( + philosopherType: "", + philosopherLabel: "", + typeName: "", + description: "", + imageURL: "" + ) } diff --git a/Projects/Domain/Entity/Sources/Profile/MyPage/MyProfile.swift b/Projects/Domain/Entity/Sources/Profile/MyPage/MyProfile.swift index 7d69db28..71cb4fbe 100644 --- a/Projects/Domain/Entity/Sources/Profile/MyPage/MyProfile.swift +++ b/Projects/Domain/Entity/Sources/Profile/MyPage/MyProfile.swift @@ -31,4 +31,13 @@ public struct MyProfile: Equatable { self.characterImageURL = characterImageURL self.mannerTemperature = mannerTemperature } + + public static let empty = MyProfile( + userTag: "", + nickname: "", + characterType: "", + characterLabel: "", + characterImageURL: "", + mannerTemperature: 0 + ) } diff --git a/Projects/Domain/Entity/Sources/Profile/MyPage/MyTier.swift b/Projects/Domain/Entity/Sources/Profile/MyPage/MyTier.swift index ed4cdfd3..3d377566 100644 --- a/Projects/Domain/Entity/Sources/Profile/MyPage/MyTier.swift +++ b/Projects/Domain/Entity/Sources/Profile/MyPage/MyTier.swift @@ -21,4 +21,6 @@ public struct MyTier: Equatable { self.tierLabel = tierLabel self.currentPoint = currentPoint } + + public static let empty = MyTier(tierCode: "", tierLabel: "", currentPoint: 0) } From 2e200bbc4ba7b46d0b92f745b751336d421d9f66 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 19:16:00 +0900 Subject: [PATCH 24/39] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B2=A0=ED=95=99=EC=9E=90=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=E2=80=94=20=EB=AF=B8=ED=99=95=EC=A0=95(=EB=B0=B0?= =?UTF-8?q?=ED=8B=80=205=EA=B0=9C=20=EB=AF=B8=EB=A7=8C)=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20=EC=9D=B4=EB=AF=B8=EC=A7=80(.lock)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isPhilosopherLocked 시 brain placeholder 대신 Image(asset: .lock) 노출 #9 --- .../Coordinator/Reducer/ProfileCoordinator.swift | 2 ++ .../Profile/Sources/Main/View/ProfileView.swift | 11 +++++++---- .../Profile/lock.imageset/Contents.json | 12 ++++++++++++ ...353\252\251-\354\227\206\354\235\214-2 1.png" | Bin 0 -> 1623 bytes .../DesignSystem/Sources/Image/ImageAsset.swift | 1 + 5 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/lock.imageset/Contents.json create mode 100644 "Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/lock.imageset/\354\240\234\353\252\251-\354\227\206\354\235\214-2 1.png" diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift index ec0d58b3..0b689fdb 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift @@ -196,3 +196,5 @@ extension ProfileCoordinator { // swiftformat:enable extensionAccessControl extension ProfileCoordinator.ProfileScreen.State: Equatable {} + + diff --git a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift index 1488c10a..5823ee85 100644 --- a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift +++ b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift @@ -173,7 +173,13 @@ private extension ProfileView { ZStack { RoundedRectangle(cornerRadius: 8) .fill(.beige200) - if let urlString = store.philosopherImageURL, let url = URL(string: urlString) { + if store.isPhilosopherLocked { + // 배틀 5개 미만(미확정) → 잠금 이미지 + Image(asset: .lock) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } else if let urlString = store.philosopherImageURL, let url = URL(string: urlString) { KFImage(url) .resizable() .scaledToFit() @@ -185,9 +191,6 @@ private extension ProfileView { } } .frame(width: 40, height: 40) - // 잠금(??형) 시 철학자 아바타 그레이 처리 - .grayscale(store.isPhilosopherLocked ? 1 : 0) - .opacity(store.isPhilosopherLocked ? 0.7 : 1) VStack(alignment: .leading, spacing: 4) { Text("나의 철학자 유형") diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/lock.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/lock.imageset/Contents.json new file mode 100644 index 00000000..cdd8a97c --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/lock.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "제목-없음-2 1.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/lock.imageset/\354\240\234\353\252\251-\354\227\206\354\235\214-2 1.png" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/lock.imageset/\354\240\234\353\252\251-\354\227\206\354\235\214-2 1.png" new file mode 100644 index 0000000000000000000000000000000000000000..228e9c39db5da1c47b6ab6c0f67598d91b31cd48 GIT binary patch literal 1623 zcmV-d2B`UoP)t+BsIFf}IBKr|*U(ZmI!6;M%8iVCs~`!?_0 ze&2;@U^x-oK1dgDX_8Zj|3A^Z02`{Cln zi_afCc+h?O_HAcjVd0&Wl$34wU5Wp|##jLV96EI9yRNP-b@uF8yP%*z<>lq6BS(&? z%a<>!XV0FgojZ4SMn*FU6N16Ff$vnwkrOQoi!hW;PH&D`AF{ey#pDmgjXTD^L;wQJWd=ZY08a)=HVnUR$Y23PXYh6`Um0GrJnOeVoeZJ@K6IHct zjorI$=emb$8|s{@9HhD!VT z`aZsU_wLY$4>kI8{`~nV=gyt`v#6*@`uqFk(W6JPb#--3qy74lxe0{fPNy^D&6_v! z`0-=eyLWE@l&i|i%eUcvaP8W)&*8MVv9a;Dii(Pwk$r0M;>9Ne0|OO>xOwyDHn7y> z>D*Uf8&m1{_3PJdsMHJ&4i=@9Se9i#&4MtAq%fxzovMMmoHz60Rzf3 z^z`pdO-&Y(ax7iCl*>O&Ca~<-vEvJjKAAalrin{{T9^QahgK>O3X+nRmKJe198|6V zxwN&lF*yf+9~c-QOL1^lPavMiJdN4e8#ZkC5e{s&1QaY6E?gjOIxquZBuYa=gJuAz zG#gALEG$ejf;!8+d-p78;-+|MIj?~+@H0WxG>kzrK0aPp-@}Iw3l0QvrMA;54q$5LA{dS>k{uq8BV!5HXR!c2ZK(uRx<~OOZgCa_rbKLH#r`)KF7=C+40#6Ua9o;`a6Lqr}vd?*|s z=g*%P(oe^rQ79}o#ViE{M>Vq~Q>RWfo;-Q-5t3|t8)CN3FBlSbXlSV5Skv{yW{vRl z>C=*vlOsT&A8v1N=Y$fx1W0XdtxkmdNHgfQLBA8zt3tfk=)fk=&WMPJ4?w4hY&B&> z!|x0@ckW!da^;Gq73GlD)>gsr&;hApvNC)2Y^`u0Ht_lVNDSqRYK0T!I5wx_^fB1C zq>RBRBsyjytTERayrZK-*O%?&799H<`*mMqVr_H#_J!C$ZTADQL`6kuB{L`;vZkyh zTZp|$K@lx$P66`1ySrO3FtqBih8*lN8SY(Myx16V0ZA$XB%xjg9Z)Pphk9vLM8kk= z83rQFZZQ!(pJ5WXH}K;3Az12=aZWJ9BMB=%KfemWlc8z_8g&9QX3Ws&NOfFXoNgyF zK>B$RHK!X64Az)!_!nr77aumJ#<&9S-@kuy&YU^d5cGZABUYrMLD>e;;Ly|Tt z>WLgjp=N_eZ`mACn3^+T5+6kia|~cb@@#0RkuRh@`@anmx`>^tFu={fxf;N_+&JNMLWz z@~trlNHfq<1t>)}kj9Y()*mkjVJKIhjH70M?OzEmT(~egHa0e1{` Date: Wed, 10 Jun 2026 19:25:19 +0900 Subject: [PATCH 25/39] =?UTF-8?q?fix:=20recap=20=EB=B9=88=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5(=EB=B0=B0=ED=8B=80=205=EA=B0=9C=20=EB=AF=B8=EB=A7=8C)?= =?UTF-8?q?=20=EC=9E=A0=EA=B8=88=20=ED=99=94=EB=A9=B4=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버가 data:nil(200)로 응답 시 에러 던지지 않고 PhilosopherRecap.empty 반환 - totalParticipation 0 → isLocked → RecapLockedView 노출 (스켈레톤 멈춤 버그 수정) - empty 헬퍼를 Model private → Entity public static 으로 중앙화 --- .../Profile/Mapper/RecapDataDTO+.swift | 37 ------------------- .../Profile/ProfileRepositoryImpl.swift | 7 ++-- .../Profile/Recap/PhilosopherRecap.swift | 9 +++++ .../Profile/Recap/PreferenceReport.swift | 7 ++++ .../Sources/Profile/Recap/RecapCard.swift | 9 +++++ .../Sources/Profile/Recap/RecapScores.swift | 4 ++ .../{ => Profile}/vs.imageset/Contents.json | 0 .../{ => Profile}/vs.imageset/versus_home.svg | 0 8 files changed, 33 insertions(+), 40 deletions(-) rename Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/{ => Profile}/vs.imageset/Contents.json (100%) rename Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/{ => Profile}/vs.imageset/versus_home.svg (100%) diff --git a/Projects/Data/Model/Sources/Profile/Mapper/RecapDataDTO+.swift b/Projects/Data/Model/Sources/Profile/Mapper/RecapDataDTO+.swift index fb0ac302..1a425cfa 100644 --- a/Projects/Data/Model/Sources/Profile/Mapper/RecapDataDTO+.swift +++ b/Projects/Data/Model/Sources/Profile/Mapper/RecapDataDTO+.swift @@ -64,40 +64,3 @@ public extension FavoriteTopicDTO { ) } } - -private extension RecapCard { - static var empty: RecapCard { - RecapCard( - philosopherType: "", - philosopherLabel: "", - typeName: "", - description: "", - keywordTags: [], - imageURL: "" - ) - } -} - -private extension RecapScores { - static var empty: RecapScores { - RecapScores( - principle: 0, - reason: 0, - individual: 0, - change: 0, - inner: 0, - ideal: 0 - ) - } -} - -private extension PreferenceReport { - static var empty: PreferenceReport { - PreferenceReport( - totalParticipation: 0, - opinionChanges: 0, - battleWinRate: 0, - favoriteTopics: [] - ) - } -} diff --git a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift index 0501c171..d1d1c947 100644 --- a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift @@ -39,10 +39,11 @@ public final class ProfileRepositoryImpl: ProfileInterface, @unchecked Sendable public func fetchRecap() async throws -> PhilosopherRecap { let dto: RecapResponseDTO = try await provider.request(.recap) + // 배틀 5개 미만 사용자는 서버가 data: nil (200) 로 응답 → 잠금 상태. + // 에러로 던지지 않고 빈 recap(totalParticipation 0 → isLocked)으로 반환. guard let data = dto.data else { - let message = dto.error?.message ?? "리캡 응답이 비어 있습니다" - Log.error("[ProfileRepositoryImpl] empty recap payload: \(message)") - throw ProfileError.backendError(message) + Log.info("[ProfileRepositoryImpl] recap 빈 응답 → 잠금 화면 반환") + return .empty } return data.toDomain() diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/PhilosopherRecap.swift b/Projects/Domain/Entity/Sources/Profile/Recap/PhilosopherRecap.swift index 3e45c6d7..a37211ef 100644 --- a/Projects/Domain/Entity/Sources/Profile/Recap/PhilosopherRecap.swift +++ b/Projects/Domain/Entity/Sources/Profile/Recap/PhilosopherRecap.swift @@ -27,4 +27,13 @@ public struct PhilosopherRecap: Equatable { self.scores = scores self.preferenceReport = preferenceReport } + + /// 잠금/빈 응답용 — totalParticipation 0 → 잠금 판정. + public static let empty = PhilosopherRecap( + myCard: .empty, + bestMatchCard: .empty, + worstMatchCard: .empty, + scores: .empty, + preferenceReport: .empty + ) } diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/PreferenceReport.swift b/Projects/Domain/Entity/Sources/Profile/Recap/PreferenceReport.swift index 5efb928b..55202376 100644 --- a/Projects/Domain/Entity/Sources/Profile/Recap/PreferenceReport.swift +++ b/Projects/Domain/Entity/Sources/Profile/Recap/PreferenceReport.swift @@ -25,4 +25,11 @@ public struct PreferenceReport: Equatable { self.battleWinRate = battleWinRate self.favoriteTopics = favoriteTopics } + + public static let empty = PreferenceReport( + totalParticipation: 0, + opinionChanges: 0, + battleWinRate: 0, + favoriteTopics: [] + ) } diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/RecapCard.swift b/Projects/Domain/Entity/Sources/Profile/Recap/RecapCard.swift index 4fa5f41c..b010b8a6 100644 --- a/Projects/Domain/Entity/Sources/Profile/Recap/RecapCard.swift +++ b/Projects/Domain/Entity/Sources/Profile/Recap/RecapCard.swift @@ -31,4 +31,13 @@ public struct RecapCard: Equatable { self.keywordTags = keywordTags self.imageURL = imageURL } + + public static let empty = RecapCard( + philosopherType: "", + philosopherLabel: "", + typeName: "", + description: "", + keywordTags: [], + imageURL: "" + ) } diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift b/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift index fe5c6ddf..b63c11c5 100644 --- a/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift +++ b/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift @@ -31,6 +31,10 @@ public struct RecapScores: Equatable { self.ideal = ideal } + public static let empty = RecapScores( + principle: 0, reason: 0, individual: 0, change: 0, inner: 0, ideal: 0 + ) + /// 레이더 각도 순서 (원칙↑ → 시계방향: 이성·개인·변화·내면·직관). public var axes: [RecapScoreAxis] { [ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/vs.imageset/Contents.json similarity index 100% rename from Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/Contents.json rename to Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/vs.imageset/Contents.json diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/versus_home.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/vs.imageset/versus_home.svg similarity index 100% rename from Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/versus_home.svg rename to Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/vs.imageset/versus_home.svg From bb93caa2a949f2da9b9427e6414c8a56712c6dd2 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 19:26:09 +0900 Subject: [PATCH 26/39] =?UTF-8?q?fix:=20=EB=82=98=EC=9D=98=20=EC=B2=A0?= =?UTF-8?q?=ED=95=99=EC=9E=90=20=EC=9C=A0=ED=98=95=20=EC=9E=A0=EA=B8=88=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=3F=3F=ED=98=95=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=EC=9D=84=20lock=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Recap/View/Components/RecapLockedView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift index 34b88cca..33a290cf 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift @@ -48,12 +48,12 @@ private extension RecapLockedView { ZStack { Circle().fill(.beige600) - Image(systemName: "brain.head.profile") - .font(.system(size: 30)) - .foregroundStyle(.gray300) + Image(asset: .lock) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) } .frame(width: 68, height: 68) - .grayscale(1) .opacity(0.7) Text("아직 분석할 기록이 부족해요.\n배틀에 참여하면 성향을 확인할 수 있어요!") From 8084a05ed47fea115b706bca0ba3634f1b3bf0a4 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 19:27:33 +0900 Subject: [PATCH 27/39] =?UTF-8?q?chore:=20fastlane=20match=20keychain=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=20unlock=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fastlane 실행 전에 match keychain을 env 기반으로 unlock해 macOS 키체인 비밀번호 팝업을 줄인다. --- README.md | 3 +++ fastlane/Fastfile | 32 +++++++++++++++++++++++++++++--- fastlane/Matchfile | 1 + 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b30e5bc2..b160810c 100644 --- a/README.md +++ b/README.md @@ -357,10 +357,13 @@ tuist test # 전체 테스트 실행 ### 🚀 배포 ```bash +export MATCH_KEYCHAIN_PASSWORD="" bundle exec fastlane ios QA # TestFlight 업로드 bundle exec fastlane ios release # App Store 배포 ``` +`MATCH_KEYCHAIN_PASSWORD`가 설정되어 있으면 fastlane이 `match_keychain`을 먼저 unlock해서 macOS 키체인 비밀번호 팝업을 줄입니다. + ## 📄 라이선스 이 프로젝트는 **MIT 라이선스** 하에 배포됩니다. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index f4d512c4..346d6efd 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -11,6 +11,7 @@ BUNDLE_ID = "io.Picke.co" OUTPUT_DIR = "./fastlane/output" IPA_FILENAME = "#{APP_NAME}.ipa" TEAM_ID = ENV["TEAM_ID"] || "N94CS4N6VR" +MATCH_KEYCHAIN_NAME = ENV["MATCH_KEYCHAIN_NAME"] || "match_keychain" # 릴리스용 변수 (누락되어 있던 부분 보완) APP_RELEASE_NAME = APP_NAME @@ -19,9 +20,30 @@ APP_RELEASE_SCHEME = SCHEME APP_STAGE_NAME = STAGE_SCHEME APP_STAGE_SCHEME = STAGE_SCHEME -platform :ios do +def unlock_match_keychain + keychain_password = ENV["MATCH_KEYCHAIN_PASSWORD"].to_s + if keychain_password.empty? + UI.important("MATCH_KEYCHAIN_PASSWORD is missing. macOS may ask for the match keychain password.") + return + end + keychain_path = File.expand_path("~/Library/Keychains/#{MATCH_KEYCHAIN_NAME}-db") + unless File.exist?(keychain_path) + UI.message("match keychain does not exist yet: #{keychain_path}") + return + end + unlock_keychain( + path: keychain_path, + password: keychain_password, + add_to_search_list: true + ) +end + +platform :ios do + before_all do + unlock_match_keychain + end # 🚀 TestFlight 업로드 (Debug) desc "Upload to TestFlight (Debug)" @@ -83,7 +105,9 @@ platform :ios do readonly: true, git_url: "git@github.com:Roy-wonji/FastlaneMatch.git", storage_mode: "git", - app_identifier: ["io.Picke.co"] + app_identifier: ["io.Picke.co"], + keychain_name: MATCH_KEYCHAIN_NAME, + keychain_password: ENV["MATCH_KEYCHAIN_PASSWORD"] ) ipa_path = nil @@ -245,7 +269,9 @@ platform :ios do readonly: true, git_url: "git@github.com:Roy-wonji/FastlaneMatch.git", storage_mode: "git", - app_identifier: ["io.Picke.co"] + app_identifier: ["io.Picke.co"], + keychain_name: MATCH_KEYCHAIN_NAME, + keychain_password: ENV["MATCH_KEYCHAIN_PASSWORD"] ) ipa_path = nil diff --git a/fastlane/Matchfile b/fastlane/Matchfile index 53e8f142..4370553a 100644 --- a/fastlane/Matchfile +++ b/fastlane/Matchfile @@ -7,6 +7,7 @@ force_for_new_certificates(true) shallow_clone(true) skip_certificate_matching(true) skip_confirmation(true) +keychain_name(ENV["MATCH_KEYCHAIN_NAME"] || "match_keychain") keychain_password(ENV["MATCH_KEYCHAIN_PASSWORD"]) # .env 파일에서 API 키가 있으면 임시 JSON 파일 생성 후 사용 완료시 자동 삭제 From 4396702933af11de856315c65e5ba55a820b43e8 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 19:33:08 +0900 Subject: [PATCH 28/39] =?UTF-8?q?fix:=20=EB=82=98=EC=9D=98=20=EC=B2=A0?= =?UTF-8?q?=ED=95=99=EC=9E=90=20=EC=9C=A0=ED=98=95=20=EC=9E=A0=EA=B8=88=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20picke.pen=20=EC=A0=95=ED=95=A9=20#13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ??형 색상 gray500→gray800, 아바타 원 beige600→gray50(#EBEBEB) - 카드 내부 콘텐츠 패딩 20 추가 (pen Content 패딩 정합) - 성향 분석 잠금 레이더 라벨/형태 pen 정합(원칙·논리·일관성·공감·실용·직관, 거의 꽉 찬 육각형) --- .../View/Components/RecapLockedView.swift | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift index 33a290cf..9bce801f 100644 --- a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift @@ -12,10 +12,15 @@ import DesignSystem import Entity struct RecapLockedView: View { - /// 잠금 카드의 장식용 레이더(블러) 점수 — 실제 값 아님. - private let placeholderScores = RecapScores( - principle: 70, reason: 60, individual: 78, change: 45, inner: 65, ideal: 55 - ) + /// 잠금 장식용 레이더(블러) — picke.pen 잠금 그래프 라벨/형태(거의 꽉 찬 육각형). + private let placeholderAxes: [RecapScoreAxis] = [ + RecapScoreAxis(label: "원칙", value: 95), + RecapScoreAxis(label: "논리", value: 88), + RecapScoreAxis(label: "일관성", value: 85), + RecapScoreAxis(label: "공감", value: 80), + RecapScoreAxis(label: "실용", value: 88), + RecapScoreAxis(label: "직관", value: 88), + ] var body: some View { ScrollView { @@ -43,18 +48,17 @@ private extension RecapLockedView { .foregroundStyle(.primary500) Text("??형") .pretendardFont(family: .SemiBold, size: 24) - .foregroundStyle(.gray500) + .foregroundStyle(.gray800) } ZStack { - Circle().fill(.beige600) + Circle().fill(.gray50) Image(asset: .lock) .resizable() .scaledToFit() .frame(width: 30, height: 30) } .frame(width: 68, height: 68) - .opacity(0.7) Text("아직 분석할 기록이 부족해요.\n배틀에 참여하면 성향을 확인할 수 있어요!") .pretendardFont(family: .SemiBold, size: 14) @@ -62,9 +66,10 @@ private extension RecapLockedView { .multilineTextAlignment(.center) .frame(maxWidth: .infinity) } + .padding(.vertical, 20) + .frame(maxWidth: .infinity) .padding(.vertical, 16) .padding(.horizontal, 24) - .frame(maxWidth: .infinity) .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) .overlay( RoundedRectangle(cornerRadius: 2) @@ -85,7 +90,7 @@ private extension RecapLockedView { .foregroundStyle(.gray800) ZStack { - RecapRadarChart(axes: placeholderScores.axes) + RecapRadarChart(axes: placeholderAxes) .frame(height: 172) .opacity(0.4) .blur(radius: 4.375) From 4c06a2fd7ae601cf2e33bd9332915d4b0359173f Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 20:32:05 +0900 Subject: [PATCH 29/39] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=EB=B0=9B?= =?UTF-8?q?=EA=B8=B0(Notification)=20=ED=99=94=EB=A9=B4=20+=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20/=20Clean=20Architecture=20=ED=92=80?= =?UTF-8?q?=EC=8A=A4=ED=83=9D=20#21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 데이터 계층: NotificationAPI/Service/DTO/Mapper/Entity/Interface/UseCase/Repository + DI 등록 - GET /notifications(목록·카테고리·페이징), GET /{id}(상세, 화면 연결 보류), POST /{id}/read, POST /read-all - PieckeDomain.notification 추가 - Notification 모듈: NotificationCoordinator + NotificationFeature/View (picke.pen 알림받기 정합) - 카테고리 탭(전체·콘텐츠·공지사항·이벤트), 무한 스크롤, 탭 시 읽음/모두 읽음 - 스켈레톤(SkeletonBlock)·빈화면(PickeEmptyStateView) Profile 패턴 적용 - Home·Profile·Hifi 종 아이콘 → NotificationCoordinator 진입 연결 --- Projects/App/Sources/Di/DiRegister.swift | 1 + .../Data/API/Sources/Base/PieckeDomain.swift | 3 + .../Notification/NotificationAPI.swift | 32 +++ .../DTO/NotificationDataDTO.swift | 40 ++++ .../Mapper/NotificationDataDTO+.swift | 57 +++++ .../NotificationRepositoryImpl.swift | 74 ++++++ .../Notification/NotificationService.swift | 62 +++++ .../NotificationsQueryRequest.swift | 25 +++ .../DefaultNotificationRepositoryImpl.swift | 37 +++ .../Notification/NotificationInterface.swift | 38 ++++ .../Notification/NotificationCategory.swift | 36 +++ .../Notification/NotificationDetail.swift | 44 ++++ .../Notification/NotificationError.swift | 44 ++++ .../Notification/NotificationItem.swift | 69 ++++++ .../Notification/NotificationPage.swift | 21 ++ .../Notification/NotificationUseCase.swift | 54 +++++ Projects/Presentation/Hifi/Project.swift | 1 + .../Coordinator/Reducer/HifiCoordinator.swift | 11 + .../View/HifiCoordinatorView.swift | 4 + .../Hifi/Sources/Reducer/HifiFeature.swift | 6 + .../Hifi/Sources/View/HifiView.swift | 2 +- Projects/Presentation/Home/Project.swift | 3 +- .../Coordinator/Reducer/HomeCoordinator.swift | 11 + .../View/HomeCoordinatorView.swift | 4 + .../Sources/Main/Reducer/HomeFeature.swift | 10 +- .../Home/Sources/Main/View/HomeView.swift | 2 +- .../Presentation/Notification/Project.swift | 19 ++ .../Notification/Sources/Base.swift | 22 ++ .../Reducer/NotificationCoordinator.swift | 119 ++++++++++ .../View/NotificationCoordinatorView.swift | 28 +++ .../Main/Reducer/NotificationFeature.swift | 211 ++++++++++++++++++ .../Components/NotificationSkeletonView.swift | 56 +++++ .../Sources/Main/View/NotificationView.swift | 192 ++++++++++++++++ .../Tests/Sources/NotificationTests.swift | 27 +++ Projects/Presentation/Profile/Project.swift | 1 + .../Reducer/ProfileCoordinator.swift | 11 +- .../View/ProfileCoordinatorView.swift | 4 + 37 files changed, 1373 insertions(+), 8 deletions(-) create mode 100644 Projects/Data/API/Sources/Notification/NotificationAPI.swift create mode 100644 Projects/Data/Model/Sources/Notification/DTO/NotificationDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Notification/Mapper/NotificationDataDTO+.swift create mode 100644 Projects/Data/Repository/Sources/Notification/NotificationRepositoryImpl.swift create mode 100644 Projects/Data/Service/Sources/Notification/NotificationService.swift create mode 100644 Projects/Data/Service/Sources/Notification/NotificationsQueryRequest.swift create mode 100644 Projects/Domain/DomainInterface/Sources/Notification/DefaultNotificationRepositoryImpl.swift create mode 100644 Projects/Domain/DomainInterface/Sources/Notification/NotificationInterface.swift create mode 100644 Projects/Domain/Entity/Sources/Notification/NotificationCategory.swift create mode 100644 Projects/Domain/Entity/Sources/Notification/NotificationDetail.swift create mode 100644 Projects/Domain/Entity/Sources/Notification/NotificationError.swift create mode 100644 Projects/Domain/Entity/Sources/Notification/NotificationItem.swift create mode 100644 Projects/Domain/Entity/Sources/Notification/NotificationPage.swift create mode 100644 Projects/Domain/UseCase/Sources/Notification/NotificationUseCase.swift create mode 100644 Projects/Presentation/Notification/Project.swift create mode 100644 Projects/Presentation/Notification/Sources/Base.swift create mode 100644 Projects/Presentation/Notification/Sources/Coordinator/Reducer/NotificationCoordinator.swift create mode 100644 Projects/Presentation/Notification/Sources/Coordinator/View/NotificationCoordinatorView.swift create mode 100644 Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift create mode 100644 Projects/Presentation/Notification/Sources/Main/View/Components/NotificationSkeletonView.swift create mode 100644 Projects/Presentation/Notification/Sources/Main/View/NotificationView.swift create mode 100644 Projects/Presentation/Notification/Tests/Sources/NotificationTests.swift diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index ccffceb3..4800efa5 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -42,6 +42,7 @@ public final class AppDIManager: Sendable { .register { SearchRepositoryImpl() as SearchInterface } .register { AudioPlayerRepositoryImpl() as AudioPlayerInterface } .register { ProfileRepositoryImpl() as ProfileInterface } + .register { NotificationRepositoryImpl() as NotificationInterface } // .register { AppUpdateRepositoryImpl() as AppUpdateInterface } // 🔐 OAuth Provider 계층 (PFW 조합 패턴) diff --git a/Projects/Data/API/Sources/Base/PieckeDomain.swift b/Projects/Data/API/Sources/Base/PieckeDomain.swift index 5c177847..27654d8b 100644 --- a/Projects/Data/API/Sources/Base/PieckeDomain.swift +++ b/Projects/Data/API/Sources/Base/PieckeDomain.swift @@ -18,6 +18,7 @@ public enum PieckeDomain { case comment case perspective case search + case notification } extension PieckeDomain: DomainType { @@ -43,6 +44,8 @@ extension PieckeDomain: DomainType { return "api/v1/perspectives/" case .search: return "api/v1/search/" + case .notification: + return "api/v1/notifications" } } } diff --git a/Projects/Data/API/Sources/Notification/NotificationAPI.swift b/Projects/Data/API/Sources/Notification/NotificationAPI.swift new file mode 100644 index 00000000..8ca83ab0 --- /dev/null +++ b/Projects/Data/API/Sources/Notification/NotificationAPI.swift @@ -0,0 +1,32 @@ +// +// NotificationAPI.swift +// API +// +// 알림(notifications) 엔드포인트 경로. (domain = "api/v1/notifications") +// + +import Foundation + +public enum NotificationAPI { + /// GET /api/v1/notifications + case list + /// GET /api/v1/notifications/{notificationId} + case detail(notificationId: Int) + /// POST /api/v1/notifications/{notificationId}/read + case read(notificationId: Int) + /// POST /api/v1/notifications/read-all + case readAll + + public var description: String { + switch self { + case .list: + return "" + case let .detail(notificationId): + return "/\(notificationId)" + case let .read(notificationId): + return "/\(notificationId)/read" + case .readAll: + return "/read-all" + } + } +} diff --git a/Projects/Data/Model/Sources/Notification/DTO/NotificationDataDTO.swift b/Projects/Data/Model/Sources/Notification/DTO/NotificationDataDTO.swift new file mode 100644 index 00000000..68eac917 --- /dev/null +++ b/Projects/Data/Model/Sources/Notification/DTO/NotificationDataDTO.swift @@ -0,0 +1,40 @@ +// +// NotificationDataDTO.swift +// Model +// +// `GET /api/v1/notifications` 및 단건 상세 응답 DTO. +// + +import Foundation + +public struct NotificationDataDTO: Decodable { + public let items: [NotificationItemDTO]? + public let hasNext: Bool? +} + +public struct NotificationItemDTO: Decodable { + public let notificationId: Int? + public let category: String? + public let detailCode: String? + public let title: String? + public let body: String? + public let referenceId: Int? + public let isRead: Bool? + public let createdAt: String? +} + +/// 단건 상세 (목록 항목 + readAt). +public struct NotificationDetailDTO: Decodable { + public let notificationId: Int? + public let category: String? + public let detailCode: String? + public let title: String? + public let body: String? + public let referenceId: Int? + public let isRead: Bool? + public let createdAt: String? + public let readAt: String? +} + +public typealias NotificationResponseDTO = BaseResponseDTO +public typealias NotificationDetailResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Notification/Mapper/NotificationDataDTO+.swift b/Projects/Data/Model/Sources/Notification/Mapper/NotificationDataDTO+.swift new file mode 100644 index 00000000..87a03728 --- /dev/null +++ b/Projects/Data/Model/Sources/Notification/Mapper/NotificationDataDTO+.swift @@ -0,0 +1,57 @@ +// +// NotificationDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension NotificationDataDTO { + func toDomain() -> NotificationPage { + NotificationPage( + items: (items ?? []).map { $0.toDomain() }, + hasNext: hasNext ?? false + ) + } +} + +public extension NotificationItemDTO { + func toDomain() -> NotificationItem { + NotificationItem( + notificationId: notificationId ?? 0, + category: NotificationCategory(rawValue: category ?? ""), + detailCode: detailCode ?? "", + title: title ?? "", + body: body ?? "", + referenceId: referenceId, + isRead: isRead ?? false, + createdAt: createdAt.flatMap(NotificationDateParser.parseISO8601) + ) + } +} + +public extension NotificationDetailDTO { + func toDomain() -> NotificationDetail { + NotificationDetail( + notificationId: notificationId ?? 0, + category: NotificationCategory(rawValue: category ?? ""), + detailCode: detailCode ?? "", + title: title ?? "", + body: body ?? "", + referenceId: referenceId, + isRead: isRead ?? false, + createdAt: createdAt.flatMap(NotificationDateParser.parseISO8601), + readAt: readAt.flatMap(NotificationDateParser.parseISO8601) + ) + } +} + +enum NotificationDateParser { + static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} diff --git a/Projects/Data/Repository/Sources/Notification/NotificationRepositoryImpl.swift b/Projects/Data/Repository/Sources/Notification/NotificationRepositoryImpl.swift new file mode 100644 index 00000000..2806fd5c --- /dev/null +++ b/Projects/Data/Repository/Sources/Notification/NotificationRepositoryImpl.swift @@ -0,0 +1,74 @@ +// +// NotificationRepositoryImpl.swift +// Repository +// + +import Foundation + +import DomainInterface +import Entity +import Model +import Service + +import LogMacro +import Moya + +@preconcurrency import AsyncMoya + +public final class NotificationRepositoryImpl: NotificationInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } + + public func fetchNotifications( + category: NotificationCategory, + page: Int, + size: Int + ) async throws -> NotificationPage { + let dto: NotificationResponseDTO = try await provider.request( + .list( + query: NotificationsQueryRequest( + category: category.rawValue, + page: page, + size: size + ) + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "알림 응답이 비어 있습니다" + Log.error("[NotificationRepositoryImpl] empty notifications payload: \(message)") + throw NotificationError.backendError(message) + } + + return data.toDomain() + } + + public func fetchNotificationDetail(notificationId: Int) async throws -> NotificationDetail { + let dto: NotificationDetailResponseDTO = try await provider.request( + .detail(notificationId: notificationId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "알림 상세 응답이 비어 있습니다" + Log.error("[NotificationRepositoryImpl] empty notification detail payload: \(message)") + throw NotificationError.backendError(message) + } + + return data.toDomain() + } + + public func markAsRead(notificationId: Int) async throws { + let _: BaseResponseDTO = try await provider.request( + .read(notificationId: notificationId) + ) + } + + public func markAllAsRead() async throws { + let _: BaseResponseDTO = try await provider.request(.readAll) + } +} diff --git a/Projects/Data/Service/Sources/Notification/NotificationService.swift b/Projects/Data/Service/Sources/Notification/NotificationService.swift new file mode 100644 index 00000000..5eb4a9b9 --- /dev/null +++ b/Projects/Data/Service/Sources/Notification/NotificationService.swift @@ -0,0 +1,62 @@ +// +// NotificationService.swift +// Service +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum NotificationService { + case list(query: NotificationsQueryRequest) + case detail(notificationId: Int) + case read(notificationId: Int) + case readAll +} + +extension NotificationService: BaseTargetType { + public typealias Domain = PieckeDomain + + public var domain: PieckeDomain { .notification } + + public var urlPath: String { + switch self { + case .list: + return NotificationAPI.list.description + case let .detail(notificationId): + return NotificationAPI.detail(notificationId: notificationId).description + case let .read(notificationId): + return NotificationAPI.read(notificationId: notificationId).description + case .readAll: + return NotificationAPI.readAll.description + } + } + + public var error: [Int: AsyncMoya.NetworkError]? { nil } + + public var method: Moya.Method { + switch self { + case .list, .detail: + return .get + case .read, .readAll: + return .post + } + } + + public var parameters: [String: Any]? { + switch self { + case let .list(query): + guard let dict = query.toDictionary else { return nil } + return dict.isEmpty ? nil : dict + case .detail, .read, .readAll: + return nil + } + } + + public var headers: [String: String]? { + return APIHeader.baseHeader + } +} diff --git a/Projects/Data/Service/Sources/Notification/NotificationsQueryRequest.swift b/Projects/Data/Service/Sources/Notification/NotificationsQueryRequest.swift new file mode 100644 index 00000000..54a320dc --- /dev/null +++ b/Projects/Data/Service/Sources/Notification/NotificationsQueryRequest.swift @@ -0,0 +1,25 @@ +// +// NotificationsQueryRequest.swift +// Service +// +// GET /api/v1/notifications 쿼리 파라미터. +// + +import Foundation + +public struct NotificationsQueryRequest: Encodable { + /// ALL / CONTENT / NOTICE / EVENT. + public let category: String? + public let page: Int? + public let size: Int? + + public init( + category: String? = nil, + page: Int? = nil, + size: Int? = nil + ) { + self.category = category + self.page = page + self.size = size + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Notification/DefaultNotificationRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Notification/DefaultNotificationRepositoryImpl.swift new file mode 100644 index 00000000..3fdebbc0 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Notification/DefaultNotificationRepositoryImpl.swift @@ -0,0 +1,37 @@ +// +// DefaultNotificationRepositoryImpl.swift +// DomainInterface +// + +import Entity +import Foundation + +public struct DefaultNotificationRepositoryImpl: NotificationInterface { + public init() {} + + public func fetchNotifications( + category _: NotificationCategory, + page _: Int, + size _: Int + ) async throws -> NotificationPage { + NotificationPage(items: [], hasNext: false) + } + + public func fetchNotificationDetail(notificationId: Int) async throws -> NotificationDetail { + NotificationDetail( + notificationId: notificationId, + category: .all, + detailCode: "", + title: "", + body: "", + referenceId: nil, + isRead: false, + createdAt: nil, + readAt: nil + ) + } + + public func markAsRead(notificationId _: Int) async throws {} + + public func markAllAsRead() async throws {} +} diff --git a/Projects/Domain/DomainInterface/Sources/Notification/NotificationInterface.swift b/Projects/Domain/DomainInterface/Sources/Notification/NotificationInterface.swift new file mode 100644 index 00000000..c5ac150e --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Notification/NotificationInterface.swift @@ -0,0 +1,38 @@ +// +// NotificationInterface.swift +// DomainInterface +// + +import Entity +import Foundation +import WeaveDI + +public protocol NotificationInterface: Sendable { + func fetchNotifications( + category: NotificationCategory, + page: Int, + size: Int + ) async throws -> NotificationPage + func fetchNotificationDetail(notificationId: Int) async throws -> NotificationDetail + func markAsRead(notificationId: Int) async throws + func markAllAsRead() async throws +} + +public struct NotificationRepositoryDependency: DependencyKey { + public static var liveValue: NotificationInterface { + UnifiedDI.resolve(NotificationInterface.self) ?? DefaultNotificationRepositoryImpl() + } + + public static var testValue: NotificationInterface { + UnifiedDI.resolve(NotificationInterface.self) ?? DefaultNotificationRepositoryImpl() + } + + public static var previewValue: NotificationInterface = liveValue +} + +public extension DependencyValues { + var notificationRepository: NotificationInterface { + get { self[NotificationRepositoryDependency.self] } + set { self[NotificationRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/Entity/Sources/Notification/NotificationCategory.swift b/Projects/Domain/Entity/Sources/Notification/NotificationCategory.swift new file mode 100644 index 00000000..e0bf4220 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Notification/NotificationCategory.swift @@ -0,0 +1,36 @@ +// +// NotificationCategory.swift +// Entity +// +// 알림 카테고리 (탭 + 항목 분류). API category 파라미터와 1:1. +// + +import Foundation + +public enum NotificationCategory: String, Equatable, CaseIterable, Identifiable { + case all = "ALL" + case content = "CONTENT" + case notice = "NOTICE" + case event = "EVENT" + + public var id: String { rawValue } + + /// 탭 표시명. + public var title: String { + switch self { + case .all: "전체" + case .content: "콘텐츠" + case .notice: "공지사항" + case .event: "이벤트" + } + } + + public init(rawValue: String) { + switch rawValue.uppercased() { + case "CONTENT": self = .content + case "NOTICE": self = .notice + case "EVENT": self = .event + default: self = .all + } + } +} diff --git a/Projects/Domain/Entity/Sources/Notification/NotificationDetail.swift b/Projects/Domain/Entity/Sources/Notification/NotificationDetail.swift new file mode 100644 index 00000000..f06b0d3d --- /dev/null +++ b/Projects/Domain/Entity/Sources/Notification/NotificationDetail.swift @@ -0,0 +1,44 @@ +// +// NotificationDetail.swift +// Entity +// +// 알림 단건 상세 — `GET /api/v1/notifications/{id}` (목록 항목 + readAt). +// + +import Foundation + +public struct NotificationDetail: Equatable, Identifiable { + public let notificationId: Int + public let category: NotificationCategory + public let detailCode: String + public let title: String + public let body: String + public let referenceId: Int? + public let isRead: Bool + public let createdAt: Date? + public let readAt: Date? + + public var id: Int { notificationId } + + public init( + notificationId: Int, + category: NotificationCategory, + detailCode: String, + title: String, + body: String, + referenceId: Int?, + isRead: Bool, + createdAt: Date?, + readAt: Date? + ) { + self.notificationId = notificationId + self.category = category + self.detailCode = detailCode + self.title = title + self.body = body + self.referenceId = referenceId + self.isRead = isRead + self.createdAt = createdAt + self.readAt = readAt + } +} diff --git a/Projects/Domain/Entity/Sources/Notification/NotificationError.swift b/Projects/Domain/Entity/Sources/Notification/NotificationError.swift new file mode 100644 index 00000000..f46770b5 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Notification/NotificationError.swift @@ -0,0 +1,44 @@ +// +// NotificationError.swift +// Entity +// +// 알림 도메인 표준 에러. +// + +import Foundation + +public enum NotificationError: LocalizedError, Equatable { + case networkError(String) + case decodingError(String) + case noData + case unauthorized + case backendError(String) + case serverError(Int) + case unknown(String) + + public var errorDescription: String? { + switch self { + case let .networkError(message): + "네트워크 오류: \(message)" + case let .decodingError(message): + "데이터 파싱 오류: \(message)" + case .noData: + "알림 데이터가 없습니다" + case .unauthorized: + "권한이 없습니다" + case let .backendError(message): + "알림 처리 실패: \(message)" + case let .serverError(code): + "서버 오류 (코드: \(code))" + case let .unknown(message): + "알 수 없는 오류: \(message)" + } + } +} + +public extension NotificationError { + static func from(_ error: Error) -> NotificationError { + if let notificationError = error as? NotificationError { return notificationError } + return .unknown(error.localizedDescription) + } +} diff --git a/Projects/Domain/Entity/Sources/Notification/NotificationItem.swift b/Projects/Domain/Entity/Sources/Notification/NotificationItem.swift new file mode 100644 index 00000000..3ff2d981 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Notification/NotificationItem.swift @@ -0,0 +1,69 @@ +// +// NotificationItem.swift +// Entity +// +// 알림 단건 — `GET /api/v1/notifications` items. +// + +import Foundation + +public struct NotificationItem: Equatable, Identifiable { + public let notificationId: Int + public let category: NotificationCategory + /// 세부 유형 코드 (서버 정의 문자열). + public let detailCode: String + /// 상단 보조 문구 (예: "새로운 배틀이 시작되었어요"). + public let title: String + /// 본문 (예: "“AI가 만든 그림도 예술인가?”에 지금 참여해보세요!"). + public let body: String + /// 연관 리소스 id (배틀/공지 등). 없을 수 있음. + public let referenceId: Int? + public let isRead: Bool + public let createdAt: Date? + + public var id: Int { notificationId } + + public init( + notificationId: Int, + category: NotificationCategory, + detailCode: String, + title: String, + body: String, + referenceId: Int?, + isRead: Bool, + createdAt: Date? + ) { + self.notificationId = notificationId + self.category = category + self.detailCode = detailCode + self.title = title + self.body = body + self.referenceId = referenceId + self.isRead = isRead + self.createdAt = createdAt + } + + /// 카테고리 기반 표시 아이콘 (SF Symbol). + public var iconSystemName: String { + switch category { + case .content: "flame" + case .notice: "speaker.wave.2" + case .event: "gift" + case .all: "bell" + } + } + + /// 읽음 처리된 사본. + public func markedAsRead() -> NotificationItem { + NotificationItem( + notificationId: notificationId, + category: category, + detailCode: detailCode, + title: title, + body: body, + referenceId: referenceId, + isRead: true, + createdAt: createdAt + ) + } +} diff --git a/Projects/Domain/Entity/Sources/Notification/NotificationPage.swift b/Projects/Domain/Entity/Sources/Notification/NotificationPage.swift new file mode 100644 index 00000000..80616e93 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Notification/NotificationPage.swift @@ -0,0 +1,21 @@ +// +// NotificationPage.swift +// Entity +// +// `GET /api/v1/notifications` 응답 (page 기반 페이지네이션). +// + +import Foundation + +public struct NotificationPage: Equatable { + public let items: [NotificationItem] + public let hasNext: Bool + + public init( + items: [NotificationItem], + hasNext: Bool + ) { + self.items = items + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/UseCase/Sources/Notification/NotificationUseCase.swift b/Projects/Domain/UseCase/Sources/Notification/NotificationUseCase.swift new file mode 100644 index 00000000..497da7e4 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Notification/NotificationUseCase.swift @@ -0,0 +1,54 @@ +// +// NotificationUseCase.swift +// UseCase +// + +import Foundation + +import DomainInterface +import Entity + +import ComposableArchitecture + +public struct NotificationUseCaseImpl: NotificationInterface { + @Dependency(\.notificationRepository) private var notificationRepository + + public init() {} + + public func fetchNotifications( + category: NotificationCategory, + page: Int, + size: Int + ) async throws -> NotificationPage { + return try await notificationRepository.fetchNotifications( + category: category, + page: page, + size: size + ) + } + + public func fetchNotificationDetail(notificationId: Int) async throws -> NotificationDetail { + return try await notificationRepository.fetchNotificationDetail(notificationId: notificationId) + } + + public func markAsRead(notificationId: Int) async throws { + try await notificationRepository.markAsRead(notificationId: notificationId) + } + + public func markAllAsRead() async throws { + try await notificationRepository.markAllAsRead() + } +} + +extension NotificationUseCaseImpl: DependencyKey { + public static var liveValue = NotificationUseCaseImpl() + public static var testValue = NotificationUseCaseImpl() + public static var previewValue = NotificationUseCaseImpl() +} + +public extension DependencyValues { + var notificationUseCase: NotificationUseCaseImpl { + get { self[NotificationUseCaseImpl.self] } + set { self[NotificationUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Presentation/Hifi/Project.swift b/Projects/Presentation/Hifi/Project.swift index 50aba6c9..e4ae6319 100644 --- a/Projects/Presentation/Hifi/Project.swift +++ b/Projects/Presentation/Hifi/Project.swift @@ -11,6 +11,7 @@ let project = Project.makeAppModule( settings: .settings(), dependencies: [ .Presentation(implements: .Chat), + .Presentation(implements: .Notification), .Shared(implements: .Shared), .Domain(implements: .UseCase), .SPM.composableArchitecture, diff --git a/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift b/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift index 7598c76d..6ded6b17 100644 --- a/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift +++ b/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift @@ -9,6 +9,7 @@ import Foundation import Chat import ComposableArchitecture +import Notification import TCAFlow @FlowCoordinator(screen: "HifiScreen", navigation: true) @@ -75,6 +76,15 @@ extension HifiCoordinator { // 큐레이팅 X — 탐색 루트로 복귀 return .send(.view(.backToRootAction)) + // 알림(종) 아이콘 → 알림받기 화면 진입. + case .routeAction(_, action: .hifi(.delegate(.openNotification))): + state.routes.push(.notification(.init())) + return .none + + // 알림받기 백탭 → 뒤로. + case .routeAction(_, action: .notification(.delegate(.dismiss))): + return .send(.view(.backAction)) + default: return .none } @@ -101,6 +111,7 @@ extension HifiCoordinator { public enum HifiScreen { case hifi(HifiFeature) case chat(ChatCoordinator) + case notification(NotificationCoordinator) } } diff --git a/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift b/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift index 4f37b53a..76e8aff0 100644 --- a/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift +++ b/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift @@ -9,6 +9,7 @@ import SwiftUI import Chat import ComposableArchitecture +import Notification import TCAFlow public struct HifiCoordinatorView: View { @@ -25,6 +26,9 @@ public struct HifiCoordinatorView: View { HifiView(store: hifiStore) case let .chat(chatStore): ChatCoordinatorView(store: chatStore) + case let .notification(notificationStore): + NotificationCoordinatorView(store: notificationStore) + .toolbar(.hidden, for: .tabBar) } } } diff --git a/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift b/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift index a6e9100a..5e29f521 100644 --- a/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift +++ b/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift @@ -45,6 +45,7 @@ public struct HifiFeature { case sortTapped(ExploreSort) case itemTapped(id: Int) case reachedBottom + case notificationTapped } public enum AsyncAction: Equatable { @@ -57,6 +58,8 @@ public struct HifiFeature { public enum DelegateAction: Equatable { case openBattle(battleId: Int) + /// 알림(종) 아이콘 → 알림받기 화면. + case openNotification } nonisolated enum CancelID: Hashable { @@ -110,6 +113,9 @@ extension HifiFeature { case let .itemTapped(id): return .send(.delegate(.openBattle(battleId: id))) + case .notificationTapped: + return .send(.delegate(.openNotification)) + case .reachedBottom: // 무한 스크롤: 다음 페이지가 있고 로딩 중이 아니면 추가 로드. guard state.hasNext, !state.isLoading else { return .none } diff --git a/Projects/Presentation/Hifi/Sources/View/HifiView.swift b/Projects/Presentation/Hifi/Sources/View/HifiView.swift index f1b3ce87..e9ab49a9 100644 --- a/Projects/Presentation/Hifi/Sources/View/HifiView.swift +++ b/Projects/Presentation/Hifi/Sources/View/HifiView.swift @@ -23,7 +23,7 @@ public struct HifiView: View { public var body: some View { VStack(spacing: 0) { - HifiHeaderView {} + HifiHeaderView { send(.notificationTapped) } categoryTabs() diff --git a/Projects/Presentation/Home/Project.swift b/Projects/Presentation/Home/Project.swift index 4375ac25..9c62302a 100644 --- a/Projects/Presentation/Home/Project.swift +++ b/Projects/Presentation/Home/Project.swift @@ -14,7 +14,8 @@ let project = Project.makeAppModule( .SPM.kingfisher, .Domain(implements: .UseCase), .Shared(implements: .Shared), - .Presentation(implements: .Chat) + .Presentation(implements: .Chat), + .Presentation(implements: .Notification) ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index 94aa6f13..0dbe160a 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -9,6 +9,7 @@ import Foundation import Chat import ComposableArchitecture +import Notification import TCAFlow @FlowCoordinator(screen: "HomeScreen", navigation: true) @@ -75,6 +76,15 @@ extension HomeCoordinator { // 큐레이팅 X — 홈 루트로 복귀 return .send(.view(.backToRootAction)) + // 알림(종) 아이콘 → 알림받기 화면 진입. + case .routeAction(_, action: .home(.delegate(.openNotification))): + state.routes.push(.notification(.init())) + return .none + + // 알림받기 백탭 → 뒤로. + case .routeAction(_, action: .notification(.delegate(.dismiss))): + return .send(.view(.backAction)) + default: return .none } @@ -101,6 +111,7 @@ extension HomeCoordinator { public enum HomeScreen { case home(HomeFeature) case chat(ChatCoordinator) + case notification(NotificationCoordinator) } } diff --git a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift index 6b728fbe..eb24f4f0 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift @@ -11,6 +11,7 @@ import SwiftUI import Chat import ComposableArchitecture +import Notification import TCAFlow public struct HomeCoordinatorView: View { @@ -28,6 +29,9 @@ public struct HomeCoordinatorView: View { case let .chat(chatStore): ChatCoordinatorView(store: chatStore) .toolbar(.hidden, for: .tabBar) + case let .notification(notificationStore): + NotificationCoordinatorView(store: notificationStore) + .toolbar(.hidden, for: .tabBar) } } } diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift index ba0b0101..23bdbe37 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -53,6 +53,7 @@ public struct HomeFeature { case hotBattleTapped(HotBattle) case bestBattleTapped(BestBattle) case newBattleTapped(NewBattle) + case notificationTapped } public enum Section: Equatable { @@ -74,6 +75,8 @@ public struct HomeFeature { case presentPreVote(battleId: Int) /// "더보기" → 탐색 탭으로 이동. case moveToExplore + /// 알림(종) 아이콘 → 알림받기 화면. + case openNotification } nonisolated enum CancelID: Hashable { @@ -132,6 +135,9 @@ extension HomeFeature { case let .newBattleTapped(battle): return .send(.delegate(.presentPreVote(battleId: battle.battleId))) + + case .notificationTapped: + return .send(.delegate(.openNotification)) } } @@ -184,8 +190,8 @@ extension HomeFeature { action: DelegateAction ) -> Effect { switch action { - case .presentPreVote, .moveToExplore: - .none + case .presentPreVote, .moveToExplore, .openNotification: + return .none } } } diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index 1a7c9ddd..f93426f5 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -22,7 +22,7 @@ public struct HomeView: View { public var body: some View { VStack(spacing: 0) { - HomeHeaderView { /* TODO: 알림 화면 */ } // sticky — 스크롤 영향 없음 + HomeHeaderView { send(.notificationTapped) } // sticky — 스크롤 영향 없음 ScrollView(showsIndicators: false) { if shouldShowSkeleton { diff --git a/Projects/Presentation/Notification/Project.swift b/Projects/Presentation/Notification/Project.swift new file mode 100644 index 00000000..3f0b0df4 --- /dev/null +++ b/Projects/Presentation/Notification/Project.swift @@ -0,0 +1,19 @@ +import Foundation +import ProjectDescription +import DependencyPlugin +import ProjectTemplatePlugin +import DependencyPackagePlugin + +let project = Project.makeAppModule( + name: "Notification", + bundleId: .appBundleID(name: ".Notification"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .Domain(implements: .UseCase), + .Shared(implements: .Shared), + .SPM.composableArchitecture, + .SPM.tcaFlow, + ], + sources: ["Sources/**"] +) \ No newline at end of file diff --git a/Projects/Presentation/Notification/Sources/Base.swift b/Projects/Presentation/Notification/Sources/Base.swift new file mode 100644 index 00000000..2c8db71a --- /dev/null +++ b/Projects/Presentation/Notification/Sources/Base.swift @@ -0,0 +1,22 @@ +// +// base.swift +// DDDAttendance. +// +// Created by Roy on 2026-06-10 +// Copyright © 2026 DDD , Ltd., All rights reserved. +// + +import SwiftUI + +struct BaseView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundColor(.accentColor) + Text("Hello, world!") + } + .padding() + } +} + diff --git a/Projects/Presentation/Notification/Sources/Coordinator/Reducer/NotificationCoordinator.swift b/Projects/Presentation/Notification/Sources/Coordinator/Reducer/NotificationCoordinator.swift new file mode 100644 index 00000000..4f2e0ba8 --- /dev/null +++ b/Projects/Presentation/Notification/Sources/Coordinator/Reducer/NotificationCoordinator.swift @@ -0,0 +1,119 @@ +// +// NotificationCoordinator.swift +// Notification +// +// 알림 모듈 진입점. 루트는 알림받기 목록(NotificationFeature). +// 상세 화면 연결 시 NotificationScreen 에 case 추가. +// + +import Foundation + +import ComposableArchitecture +import TCAFlow + +@FlowCoordinator(screen: "NotificationScreen", navigation: true) +public struct NotificationCoordinator { + public init() {} + + @ObservableState + public struct State: Equatable { + public var routes: [Route] + + public init() { + routes = [.root(.notification(.init()), embedInNavigationView: true)] + } + } + + @CasePathable + public enum Action { + case router(IndexedRouterActionOf) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case navigation(NavigationAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case backAction + case backToRootAction + } + + public enum AsyncAction: Equatable {} + public enum InnerAction: Equatable {} + public enum NavigationAction: Equatable {} + + public enum DelegateAction: Equatable { + /// 알림 모듈 전체를 빠져나가 부모 스택으로 복귀. + case dismiss + } + + func handleRoute( + state: inout State, + action: Action + ) -> Effect { + switch action { + case let .router(routeAction): + routerAction(state: &state, action: routeAction) + case let .view(viewAction): + handleViewAction(state: &state, action: viewAction) + case .async, .inner, .navigation: + .none + case let .delegate(delegateAction): + handleDelegateAction(state: &state, action: delegateAction) + } + } +} + +extension NotificationCoordinator { + private func routerAction( + state _: inout State, + action: IndexedRouterActionOf + ) -> Effect { + switch action { + // 목록(루트) 백탭 → 알림 모듈 종료(부모로 복귀). + case .routeAction(_, action: .notification(.delegate(.dismiss))): + return .send(.delegate(.dismiss)) + + default: + return .none + } + } + + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backAction: + state.routes.goBack() + return .none + case .backToRootAction: + state.routes.goBackToRoot() + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + } + } +} + +// swiftformat:disable extensionAccessControl +extension NotificationCoordinator { + @Reducer + public enum NotificationScreen { + case notification(NotificationFeature) + } +} + +// swiftformat:enable extensionAccessControl + +extension NotificationCoordinator.NotificationScreen.State: Equatable {} diff --git a/Projects/Presentation/Notification/Sources/Coordinator/View/NotificationCoordinatorView.swift b/Projects/Presentation/Notification/Sources/Coordinator/View/NotificationCoordinatorView.swift new file mode 100644 index 00000000..08104f0e --- /dev/null +++ b/Projects/Presentation/Notification/Sources/Coordinator/View/NotificationCoordinatorView.swift @@ -0,0 +1,28 @@ +// +// NotificationCoordinatorView.swift +// Notification +// + +import Foundation + +import SwiftUI + +import ComposableArchitecture +import TCAFlow + +public struct NotificationCoordinatorView: View { + @Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + TCAFlowRouter(store.scope(state: \.routes, action: \.router)) { screen in + switch screen.case { + case let .notification(notificationStore): + NotificationView(store: notificationStore) + } + } + } +} diff --git a/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift b/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift new file mode 100644 index 00000000..f668564a --- /dev/null +++ b/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift @@ -0,0 +1,211 @@ +// +// NotificationFeature.swift +// Notification +// +// 알림받기 — picke.pen `알림받기`. +// 카테고리 탭(전체/콘텐츠/공지사항/이벤트), GET /api/v1/notifications (page 페이지네이션), +// 탭 시 읽음 처리 / 모두 읽음. +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro +import UseCase + +@Reducer +public struct NotificationFeature { + public init() {} + + static let pageSize = 20 + + @ObservableState + public struct State: Equatable { + public var selectedTab: NotificationCategory = .all + public var isLoading: Bool = false + public var isLoadingMore: Bool = false + public var items: [NotificationItem] = [] + public var page: Int = 0 + public var hasNext: Bool = false + + public var hasUnread: Bool { + items.contains { !$0.isRead } + } + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backTapped + case tabSelected(NotificationCategory) + case reachedBottom + case readAllTapped + case notificationTapped(NotificationItem) + } + + public enum AsyncAction: Equatable { + case fetch(reset: Bool) + case markRead(notificationId: Int) + case markAll + } + + public enum InnerAction: Equatable { + case notificationsResponse(Result, reset: Bool) + } + + public enum DelegateAction: Equatable { + case dismiss + } + + nonisolated enum CancelID: Hashable { + case fetch + } + + @Dependency(\.notificationUseCase) private var notificationUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension NotificationFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard state.items.isEmpty else { return .none } + return .send(.async(.fetch(reset: true))) + + case .backTapped: + return .send(.delegate(.dismiss)) + + case let .tabSelected(tab): + guard tab != state.selectedTab else { return .none } + state.selectedTab = tab + state.items = [] + state.page = 0 + state.hasNext = false + return .send(.async(.fetch(reset: true))) + + case .reachedBottom: + guard state.hasNext, !state.isLoadingMore, !state.isLoading else { return .none } + return .send(.async(.fetch(reset: false))) + + case .readAllTapped: + guard state.hasUnread else { return .none } + state.items = state.items.map { $0.markedAsRead() } + return .send(.async(.markAll)) + + case let .notificationTapped(item): + // 상세 화면 연결 전: 탭 시 읽음 처리만. + guard !item.isRead else { return .none } + if let index = state.items.firstIndex(where: { $0.id == item.id }) { + state.items[index] = state.items[index].markedAsRead() + } + return .send(.async(.markRead(notificationId: item.notificationId))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case let .fetch(reset): + if reset { + state.isLoading = true + } else { + state.isLoadingMore = true + } + let page = reset ? 0 : state.page + let category = state.selectedTab + return .run { [useCase = notificationUseCase] send in + let result = await Result { + try await useCase.fetchNotifications( + category: category, + page: page, + size: Self.pageSize + ) + } + .mapError(NotificationError.from) + return await send(.inner(.notificationsResponse(result, reset: reset))) + } + .cancellable(id: CancelID.fetch, cancelInFlight: true) + + case let .markRead(notificationId): + return .run { [useCase = notificationUseCase] _ in + try? await useCase.markAsRead(notificationId: notificationId) + } + + case .markAll: + return .run { [useCase = notificationUseCase] _ in + try? await useCase.markAllAsRead() + } + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .notificationsResponse(result, reset): + state.isLoading = false + state.isLoadingMore = false + switch result { + case let .success(pageData): + if reset { + state.items = pageData.items + } else { + state.items.append(contentsOf: pageData.items) + } + state.hasNext = pageData.hasNext + if pageData.hasNext { state.page += 1 } + case let .failure(error): + Log.error("[NotificationFeature] fetchNotifications failed: \(error.localizedDescription)") + } + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + } + } +} diff --git a/Projects/Presentation/Notification/Sources/Main/View/Components/NotificationSkeletonView.swift b/Projects/Presentation/Notification/Sources/Main/View/Components/NotificationSkeletonView.swift new file mode 100644 index 00000000..e8846da6 --- /dev/null +++ b/Projects/Presentation/Notification/Sources/Main/View/Components/NotificationSkeletonView.swift @@ -0,0 +1,56 @@ +// +// NotificationSkeletonView.swift +// Notification +// +// 알림받기 로딩 스켈레톤 — 공통 SkeletonBlock(light). +// + +import SwiftUI + +import DesignSystem + +struct NotificationSkeletonView: View { + var body: some View { + ScrollView { + VStack(spacing: 8) { + ForEach(0 ..< 7, id: \.self) { _ in + HStack(alignment: .center, spacing: 16) { + SkeletonBlock(cornerRadius: 12, tone: .light) + .frame(width: 24, height: 24) + + VStack(alignment: .leading, spacing: 6) { + HStack { + block(width: 120, height: 12) + Spacer(minLength: 0) + block(width: 40, height: 12) + } + block(width: nil, maxWidth: true, height: 16) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + } + .padding(.top, 8) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + } + + @ViewBuilder + private func block( + width: CGFloat?, + maxWidth: Bool = false, + height: CGFloat + ) -> some View { + SkeletonBlock(cornerRadius: 4, tone: .light) + .frame(width: width) + .frame(maxWidth: maxWidth ? .infinity : nil) + .frame(height: height) + } +} diff --git a/Projects/Presentation/Notification/Sources/Main/View/NotificationView.swift b/Projects/Presentation/Notification/Sources/Main/View/NotificationView.swift new file mode 100644 index 00000000..2c6389e8 --- /dev/null +++ b/Projects/Presentation/Notification/Sources/Main/View/NotificationView.swift @@ -0,0 +1,192 @@ +// +// NotificationView.swift +// Notification +// +// 알림받기 UI — picke.pen `알림받기`. +// App Bar(뒤로/알림/모두 읽음) + 카테고리 탭 + 알림 카드 리스트 + 무한 스크롤. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity +import Utill + +@ViewAction(for: NotificationFeature.self) +public struct NotificationView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar( + onBack: { send(.backTapped) }, + centerTitle: "알림" + ) { + Button { send(.readAllTapped) } label: { + Text("모두 읽음") + .pretendardFont(family: .Regular, size: 14) + .foregroundStyle(.gray300) + } + .buttonStyle(.plain) + } + .foregroundStyle(.gray500) + + tabBar() + + Group { + if store.isLoading { + NotificationSkeletonView() + } else { + content() + } + } + .simultaneousGesture(tabSwipeGesture()) + } + .background(Color.bgDefault.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + } + + /// 좌우 드래그 → 인접 탭 전환. + func tabSwipeGesture() -> some Gesture { + DragGesture(minimumDistance: 20) + .onEnded { value in + let horizontal = value.translation.width + let vertical = value.translation.height + guard abs(horizontal) > abs(vertical), abs(horizontal) > 50 else { return } + let tabs = NotificationCategory.allCases + guard let index = tabs.firstIndex(of: store.selectedTab) else { return } + if horizontal < 0, index < tabs.count - 1 { + send(.tabSelected(tabs[index + 1])) + } else if horizontal > 0, index > 0 { + send(.tabSelected(tabs[index - 1])) + } + } + } +} + +private extension NotificationView { + // MARK: 카테고리 탭 (전체/콘텐츠/공지사항/이벤트) + + @ViewBuilder + func tabBar() -> some View { + HStack(spacing: 8) { + ForEach(NotificationCategory.allCases) { tab in + tabPill(tab) + } + Spacer(minLength: 0) + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + + @ViewBuilder + func tabPill(_ tab: NotificationCategory) -> some View { + let isSelected = store.selectedTab == tab + Button { + send(.tabSelected(tab)) + } label: { + Text(tab.title) + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(isSelected ? .beige50 : .primary500) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background(isSelected ? Color.primary500 : Color.primary50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(isSelected ? Color.clear : Color.primary500, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + // MARK: 리스트 + + @ViewBuilder + func content() -> some View { + if store.items.isEmpty { + PickeEmptyStateView(message: "받은 알림이 없어요") + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(store.items) { item in + notificationRow(item) + .onAppear { + if item.id == store.items.last?.id { + send(.reachedBottom) + } + } + } + + if store.isLoadingMore { + ProgressView().padding(.vertical, 8) + } + } + .padding(.top, 8) + .padding(.bottom, 32) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + } + } + + @ViewBuilder + func notificationRow(_ item: NotificationItem) -> some View { + Button { + send(.notificationTapped(item)) + } label: { + HStack(alignment: .center, spacing: 16) { + Image(systemName: item.iconSystemName) + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(.neutral900) + .frame(width: 24, height: 24) + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text(item.title) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + .lineLimit(1) + + Spacer(minLength: 0) + + Text(item.createdAt.relativeKoreanString) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + .fixedSize() + } + + Text(item.body) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.gray500) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + .overlay(alignment: .topTrailing) { + if !item.isRead { + RoundedRectangle(cornerRadius: 3) + .fill(.primary500) + .frame(width: 4, height: 4) + .padding(.top, 8) + .padding(.trailing, 8) + } + } + } + .buttonStyle(.plain) + } +} diff --git a/Projects/Presentation/Notification/Tests/Sources/NotificationTests.swift b/Projects/Presentation/Notification/Tests/Sources/NotificationTests.swift new file mode 100644 index 00000000..9b7d6fee --- /dev/null +++ b/Projects/Presentation/Notification/Tests/Sources/NotificationTests.swift @@ -0,0 +1,27 @@ +// +// NotificationTests.swift +// Presentation.NotificationTests +// +// Created by Roy on 2026-06-10. +// + +import Testing +@testable import Notification + +struct NotificationTests { + + @Test + func notificationExample() { + // This is an example of a test case. + #expect(true) + } + + @Test + func notificationLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } + +} + diff --git a/Projects/Presentation/Profile/Project.swift b/Projects/Presentation/Profile/Project.swift index 1d5039ff..d7016d75 100644 --- a/Projects/Presentation/Profile/Project.swift +++ b/Projects/Presentation/Profile/Project.swift @@ -15,6 +15,7 @@ let project = Project.makeAppModule( .SPM.composableArchitecture, .SPM.tcaFlow, .Presentation(implements: .Web), + .Presentation(implements: .Notification) ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift index 0b689fdb..a777b265 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift @@ -9,6 +9,7 @@ import Foundation import ComposableArchitecture import Entity +import Notification import TCAFlow import Web @@ -101,10 +102,15 @@ extension ProfileCoordinator { state.routes.push(.settings(.init())) return .none - // 알림(종) 아이콘은 현재 연결 화면 없음. + // 알림(종) 아이콘 → 알림받기 화면 진입. case .routeAction(_, action: .profile(.delegate(.openNotification))): + state.routes.push(.notification(.init())) return .none + // 알림받기 백탭 → 뒤로. + case .routeAction(_, action: .notification(.delegate(.dismiss))): + return .send(.view(.backAction)) + // 설정 상단 백탭 → 뒤로. case .routeAction(_, action: .settings(.delegate(.dismiss))): return .send(.view(.backAction)) @@ -189,6 +195,7 @@ extension ProfileCoordinator { case notificationSetting(NotificationSettingFeature) case battleProposal(BattleProposalFeature) case recap(RecapFeature) + case notification(NotificationCoordinator) case web(WebReducer) } } @@ -196,5 +203,3 @@ extension ProfileCoordinator { // swiftformat:enable extensionAccessControl extension ProfileCoordinator.ProfileScreen.State: Equatable {} - - diff --git a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift index 6a75b8ad..48a0ccc7 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI import ComposableArchitecture +import Notification import TCAFlow import Web @@ -39,6 +40,9 @@ public struct ProfileCoordinatorView: View { BattleProposalView(store: battleProposalStore) case let .recap(recapStore): RecapView(store: recapStore) + case let .notification(notificationStore): + NotificationCoordinatorView(store: notificationStore) + .toolbar(.hidden, for: .tabBar) case let .web(webStore): WebView(store: webStore) } From ea853a436d5dca96326d2ac12f7f3e0c5499e71c Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 20:58:31 +0900 Subject: [PATCH 30/39] =?UTF-8?q?feat:=20Mixpanel=20AnalyticsUseCase=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=20=E2=80=94=20=ED=83=80=EC=9E=85=20=EC=95=88?= =?UTF-8?q?=EC=A0=84=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=8A=B8=EB=9E=98?= =?UTF-8?q?=ED=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UseCase 모듈에 AnalyticsUseCase 추가 (auth/battle/session 이벤트 + 공통 유저 프로퍼티) - 로그인 성공/실패·회원가입 성공 시 Mixpanel 트래킹 + identify - UseCase Project 에 Mixpanel/SessionReplay 의존성 추가 - 인프라(SPM·App init·SessionReplay·토큰)는 기존 구성 활용 --- Projects/Domain/UseCase/Project.swift | 8 +- .../Sources/Analytics/AnalyticsUseCase.swift | 240 ++++++++++++++++++ .../Sources/Main/Reducer/LoginFeature.swift | 22 ++ 3 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 Projects/Domain/UseCase/Sources/Analytics/AnalyticsUseCase.swift diff --git a/Projects/Domain/UseCase/Project.swift b/Projects/Domain/UseCase/Project.swift index 63d0f4da..af1b0fbb 100644 --- a/Projects/Domain/UseCase/Project.swift +++ b/Projects/Domain/UseCase/Project.swift @@ -1,18 +1,20 @@ +import DependencyPackagePlugin +import DependencyPlugin import Foundation import ProjectDescription -import DependencyPlugin import ProjectTemplatePlugin -import DependencyPackagePlugin let project = Project.makeModule( name: "UseCase", bundleId: .appBundleID(name: ".UseCase"), product: .staticFramework, - settings: .settings(), + settings: .settings(), dependencies: [ .Domain(implements: .DomainInterface), .SPM.composableArchitecture, .SPM.weaveDI, + .SPM.mixpanel, + .SPM.mixpanelSessionReplay, ], sources: ["Sources/**"], hasTests: true diff --git a/Projects/Domain/UseCase/Sources/Analytics/AnalyticsUseCase.swift b/Projects/Domain/UseCase/Sources/Analytics/AnalyticsUseCase.swift new file mode 100644 index 00000000..f3c121d3 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Analytics/AnalyticsUseCase.swift @@ -0,0 +1,240 @@ +// +// AnalyticsUseCase.swift +// UseCase +// +// Mixpanel 이벤트 트래킹 — 타입 안전한 이벤트 + 공통 유저 프로퍼티. +// (TimeSpot-iOS AnalyticsUseCase 패턴을 Picke 도메인에 맞춰 적용) +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro +import Mixpanel +import MixpanelSessionReplay + +// MARK: - 이벤트 정의 + +public enum AnalyticsEvent: Sendable { + case auth(AuthEventType, AuthEventData) + case battle(BattleEventType, BattleEventData) + case session(SessionEventType, SessionEventData) +} + +public enum AuthEventType: String, Sendable { + case loginSucceeded = "login_succeeded" + case loginFailed = "login_failed" + case signupSucceeded = "signup_succeeded" + case signupFailed = "signup_failed" +} + +public struct AuthEventData: Sendable { + public let username: String? + public let userTag: String? + public let socialType: String? + public let isNewUser: Bool? + public let errorDescription: String? + + public init( + username: String? = nil, + userTag: String? = nil, + socialType: String? = nil, + isNewUser: Bool? = nil, + errorDescription: String? = nil + ) { + self.username = username + self.userTag = userTag + self.socialType = socialType + self.isNewUser = isNewUser + self.errorDescription = errorDescription + } +} + +public enum BattleEventType: String, Sendable { + case viewed = "battle_viewed" + case detailOpened = "battle_detail_opened" + case prevoteSubmitted = "battle_prevote_submitted" + case postvoteSubmitted = "battle_postvote_submitted" +} + +public struct BattleEventData: Sendable { + public let battleID: Int? + public let battleTitle: String? + public let voteSide: String? + public let source: String? + + public init( + battleID: Int? = nil, + battleTitle: String? = nil, + voteSide: String? = nil, + source: String? = nil + ) { + self.battleID = battleID + self.battleTitle = battleTitle + self.voteSide = voteSide + self.source = source + } +} + +public enum SessionEventType: String, Sendable { + case logoutSucceeded = "logout_succeeded" +} + +public struct SessionEventData: Sendable { + public let provider: String + + public init(provider: String) { + self.provider = provider + } +} + +// MARK: - UseCase + +public struct AnalyticsUseCase: Sendable { + public var track: @Sendable (_ event: AnalyticsEvent) -> Void + + public init(track: @escaping @Sendable (_ event: AnalyticsEvent) -> Void) { + self.track = track + } +} + +extension AnalyticsUseCase: DependencyKey { + public static let liveValue = AnalyticsUseCase { event in + let mixpanel = Mixpanel.mainInstance() + let userSession = currentUserSession() + + switch event { + case let .auth(type, data): + if type == .loginSucceeded || type == .signupSucceeded { + identifyIfPossible( + mixpanel: mixpanel, + userTag: data.userTag, + socialType: data.socialType, + isNewUser: data.isNewUser, + username: data.username + ) + } + let properties = authProperties(data, userSession: userSession) + #logDebug("Mixpanel track", ["event": type.rawValue, "properties": String(describing: properties)]) + mixpanel.track(event: type.rawValue, properties: properties) + + case let .battle(type, data): + let properties = battleProperties(data, userSession: userSession) + #logDebug("Mixpanel track", ["event": type.rawValue, "properties": String(describing: properties)]) + mixpanel.track(event: type.rawValue, properties: properties) + + case let .session(type, data): + let properties = sessionProperties(data, userSession: userSession) + #logDebug("Mixpanel track", ["event": type.rawValue, "properties": String(describing: properties)]) + mixpanel.track(event: type.rawValue, properties: properties) + if type == .logoutSucceeded { + mixpanel.reset() + MPSessionReplay.getInstance()?.identify(distinctId: mixpanel.distinctId) + } + } + } + + public static let testValue = AnalyticsUseCase { _ in } + public static let previewValue = testValue + + private static func identifyIfPossible( + mixpanel: MixpanelInstance, + userTag: String?, + socialType: String?, + isNewUser: Bool?, + username: String? + ) { + let distinctID = userTag?.nilIfEmpty + ?? username?.nilIfEmpty + ?? "\(socialType ?? "unknown")-\(UUID().uuidString)" + mixpanel.identify(distinctId: distinctID) + MPSessionReplay.getInstance()?.identify(distinctId: distinctID) + + var properties: Properties = [:] + if let socialType { + properties["provider"] = socialType + } + if let isNewUser { + properties["is_new_user"] = isNewUser + } + if let username, !username.isEmpty { + properties["$name"] = username + properties["username"] = username + } + if let userTag, !userTag.isEmpty { + properties["user_tag"] = userTag + } + + guard !properties.isEmpty else { return } + mixpanel.people.set(properties: properties) + } + + private static func authProperties(_ data: AuthEventData, userSession: UserSession) -> Properties { + var properties = commonUserProperties(userSession: userSession) + if let username = data.username { + properties["username"] = username + } + if let userTag = data.userTag { + properties["user_tag"] = userTag + } + if let socialType = data.socialType { + properties["social_type"] = socialType + } + if let isNewUser = data.isNewUser { + properties["is_new_user"] = isNewUser + } + if let errorDescription = data.errorDescription { + properties["error_description"] = errorDescription + } + return properties + } + + private static func battleProperties(_ data: BattleEventData, userSession: UserSession) -> Properties { + var properties = commonUserProperties(userSession: userSession) + if let battleID = data.battleID { + properties["battle_id"] = battleID + } + if let battleTitle = data.battleTitle { + properties["battle_title"] = battleTitle + } + if let voteSide = data.voteSide { + properties["vote_side"] = voteSide + } + if let source = data.source { + properties["source"] = source + } + return properties + } + + private static func sessionProperties(_ data: SessionEventData, userSession: UserSession) -> Properties { + var properties = commonUserProperties(userSession: userSession) + properties["provider"] = data.provider + return properties + } + + private static func currentUserSession() -> UserSession { + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + return userSession + } + + private static func commonUserProperties(userSession: UserSession) -> Properties { + var properties: Properties = [:] + if !userSession.name.isEmpty { + properties["username"] = userSession.name + } + properties["provider"] = userSession.provider.rawValue + return properties + } +} + +public extension DependencyValues { + var analyticsUseCase: AnalyticsUseCase { + get { self[AnalyticsUseCase.self] } + set { self[AnalyticsUseCase.self] = newValue } + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift b/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift index 472f0a21..0d7a92ec 100644 --- a/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift +++ b/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift @@ -87,6 +87,7 @@ public struct LoginFeature { @Dependency(\.appleManger) var appleLoginManger @Dependency(\.unifiedOAuthUseCase) var unifiedOAuthUseCase @Dependency(\.continuousClock) var clock + @Dependency(\.analyticsUseCase) var analyticsUseCase public var body: some Reducer { BindingReducer() @@ -198,6 +199,18 @@ extension LoginFeature { case let .success(loginEntity): state.loginEntity = loginEntity + analyticsUseCase.track( + .auth( + loginEntity.isNewUser ? .signupSucceeded : .loginSucceeded, + AuthEventData( + username: loginEntity.name, + userTag: loginEntity.userTag, + socialType: loginEntity.provider.rawValue, + isNewUser: loginEntity.isNewUser + ) + ) + ) + guard loginEntity.isNewUser else { return .send(.delegate(.presentMainTab)) } @@ -211,6 +224,15 @@ extension LoginFeature { case let .failure(error): #logNetwork("로그인 실패", error.localizedDescription) let socialType = state.currentSocialType + analyticsUseCase.track( + .auth( + .loginFailed, + AuthEventData( + socialType: socialType?.rawValue, + errorDescription: error.errorDescription ?? error.localizedDescription + ) + ) + ) return .run { _ in await MainActor.run { let errorMessage = switch socialType { From 819aaaa5fb2af99319f8ce08d2e256924382e60e Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 20:58:32 +0900 Subject: [PATCH 31/39] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EB=AF=B8?= =?UTF-8?q?=EC=9D=BD=EC=9D=8C=20=EB=B9=A8=EA=B0=84=EC=A0=90=20=E2=80=94=20?= =?UTF-8?q?=ED=99=88/=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A2=85=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20+=20=EB=AA=A8=EB=91=90=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=EC=8B=9C=20=EC=A0=9C=EA=B1=B0=20#21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HasUnreadNotification @Shared(inMemory) 전역 플래그 도입 - 알림 목록 로드(전체 탭) 시 미읽음 여부 반영, 모두 읽음/단건 읽음 시 갱신 - 홈 헤더·프로필 종 아이콘에 errorDefault 빨간점 오버레이 --- .../Home/Sources/Main/Reducer/HomeFeature.swift | 3 +++ .../Main/View/Components/HomeHeaderView.swift | 9 +++++++++ .../Home/Sources/Main/View/HomeView.swift | 2 +- .../Main/Reducer/NotificationFeature.swift | 15 +++++++++++++++ .../Sources/Main/Reducer/ProfileFeature.swift | 3 +++ .../Profile/Sources/Main/View/ProfileView.swift | 8 ++++++++ 6 files changed, 39 insertions(+), 1 deletion(-) diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift index 23bdbe37..bbac45da 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -29,6 +29,9 @@ public struct HomeFeature { public var votes: [VoteQuestion] = [] public var newBattles: [NewBattle] = [] + /// 종 아이콘 빨간점 — 미읽음 알림 존재 여부 (알림 화면과 전역 공유). + @Shared(.inMemory("HasUnreadNotification")) public var hasUnreadNotification: Bool = false + public var currentQuiz: QuizQuestion? { quizzes.first } public var currentVote: VoteQuestion? { votes.first } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift index db34097a..6fb0c1f4 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift @@ -11,6 +11,7 @@ import DesignSystem /// 홈 화면 최상단 GNB 위 헤더 (PicKé 로고 + 알림 아이콘). struct HomeHeaderView: View { + var hasUnread: Bool = false let onNotificationTapped: () -> Void var body: some View { @@ -27,6 +28,14 @@ struct HomeHeaderView: View { .resizable() .scaledToFit() .frame(width: 24, height: 24) + .overlay(alignment: .topTrailing) { + if hasUnread { + Circle() + .fill(.errorDefault) + .frame(width: 6, height: 6) + .offset(x: 1, y: -1) + } + } } } .padding(.horizontal, 24) diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index f93426f5..6ce4ffe9 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -22,7 +22,7 @@ public struct HomeView: View { public var body: some View { VStack(spacing: 0) { - HomeHeaderView { send(.notificationTapped) } // sticky — 스크롤 영향 없음 + HomeHeaderView(hasUnread: store.hasUnreadNotification) { send(.notificationTapped) } // sticky — 스크롤 영향 없음 ScrollView(showsIndicators: false) { if shouldShowSkeleton { diff --git a/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift b/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift index f668564a..cb8370c1 100644 --- a/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift +++ b/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift @@ -29,6 +29,9 @@ public struct NotificationFeature { public var page: Int = 0 public var hasNext: Bool = false + /// 홈/프로필 종 아이콘 빨간점 — 미읽음 알림 존재 여부 (전역 공유). + @Shared(.inMemory("HasUnreadNotification")) public var hasUnreadNotification: Bool = false + public var hasUnread: Bool { items.contains { !$0.isRead } } @@ -125,6 +128,8 @@ extension NotificationFeature { case .readAllTapped: guard state.hasUnread else { return .none } state.items = state.items.map { $0.markedAsRead() } + // 모두 읽음 → 홈/프로필 빨간점 제거. + state.$hasUnreadNotification.withLock { $0 = false } return .send(.async(.markAll)) case let .notificationTapped(item): @@ -133,6 +138,7 @@ extension NotificationFeature { if let index = state.items.firstIndex(where: { $0.id == item.id }) { state.items[index] = state.items[index].markedAsRead() } + updateUnreadBadge(state: &state) return .send(.async(.markRead(notificationId: item.notificationId))) } } @@ -192,6 +198,7 @@ extension NotificationFeature { } state.hasNext = pageData.hasNext if pageData.hasNext { state.page += 1 } + updateUnreadBadge(state: &state) case let .failure(error): Log.error("[NotificationFeature] fetchNotifications failed: \(error.localizedDescription)") } @@ -199,6 +206,14 @@ extension NotificationFeature { } } + /// 전체 탭 기준 미읽음 존재 여부를 전역 빨간점 플래그에 반영. + /// (카테고리 필터 탭에서는 부분 정보이므로 갱신하지 않는다.) + private func updateUnreadBadge(state: inout State) { + guard state.selectedTab == .all else { return } + let hasUnread = state.hasUnread + state.$hasUnreadNotification.withLock { $0 = hasUnread } + } + private func handleDelegateAction( state _: inout State, action: DelegateAction diff --git a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift index 6e492b52..909039fa 100644 --- a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift +++ b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift @@ -47,6 +47,9 @@ public struct ProfileFeature { /// 메뉴 목록. public var menuItems: [MenuItem] = MenuItem.allCases + /// 종 아이콘 빨간점 — 미읽음 알림 존재 여부 (알림 화면과 전역 공유). + @Shared(.inMemory("HasUnreadNotification")) public var hasUnreadNotification: Bool = false + public init() {} /// 철학자 유형 미확정(잠금) 여부. diff --git a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift index 5823ee85..2161da5f 100644 --- a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift +++ b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift @@ -64,6 +64,14 @@ private extension ProfileView { .font(.system(size: 20, weight: .regular)) .foregroundStyle(.neutral900) .frame(width: 24, height: 24) + .overlay(alignment: .topTrailing) { + if store.hasUnreadNotification { + Circle() + .fill(.errorDefault) + .frame(width: 6, height: 6) + .offset(x: 1, y: -1) + } + } } Button { send(.settingsTapped) } label: { From f3b036bb584f5c7186e5cddf7c6cf5ab1ea0bc64 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 22:17:34 +0900 Subject: [PATCH 32/39] =?UTF-8?q?chore:=20ModulePath.Presentation=20?= =?UTF-8?q?=EC=97=90=20Notification=20=EB=AA=A8=EB=93=88=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=93=B1=EB=A1=9D=20#21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TargetDependency+Module/Modules.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index 79f8a1a5..b8dae1b4 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -32,6 +32,7 @@ public extension ModulePath { public static let name: String = "Presentation" + case Notification } } From f9af07652ef98579f1ca2a2f982befda8612e72c Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 22:17:34 +0900 Subject: [PATCH 33/39] =?UTF-8?q?chore:=20=EB=B9=8C=EB=93=9C=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=2020=20=E2=86=92=2022?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Project+Templete/Extension+String.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift index 8c182788..d6792e1d 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift @@ -17,7 +17,7 @@ extension String { return Project.Environment.bundlePrefix } - public static func appBuildVersion(buildVersion: String = "20") -> String { + public static func appBuildVersion(buildVersion: String = "22") -> String { return buildVersion } From 1a3192ff91b478ba55db6ed387276860bf090a40 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 22:49:25 +0900 Subject: [PATCH 34/39] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B6=A9=EC=A0=84=20=EB=B0=94=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=E2=80=94=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=83=81=EC=84=B8=20/=20=EB=AC=B4=EB=A3=8C=20?= =?UTF-8?q?=EC=B6=A9=EC=A0=84=20=EB=A6=AC=EC=9B=8C=EB=93=9C=20=EA=B4=91?= =?UTF-8?q?=EA=B3=A0=20#9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 충전 바 좌측(보유 포인트) 탭 → 포인트 내역 상세 진입 - 무료 충전 배지 탭 → GoogleMobileAds 리워드 동영상 광고(네이티브) - RewardedAdClient 의존성 추가, 보상 획득 시 마이페이지 재조회로 포인트 갱신 - 광고 클릭 랜딩(웹뷰)은 SDK 인앱 브라우저로 자동 처리 - Profile 모듈에 googleMobileAds 의존성 추가 --- Projects/Presentation/Profile/Project.swift | 3 +- .../Profile/Sources/Ad/RewardedAdClient.swift | 110 ++++++++++++++++++ .../Sources/Main/Reducer/ProfileFeature.swift | 11 ++ .../Sources/Main/View/ProfileView.swift | 52 +++++---- 4 files changed, 154 insertions(+), 22 deletions(-) create mode 100644 Projects/Presentation/Profile/Sources/Ad/RewardedAdClient.swift diff --git a/Projects/Presentation/Profile/Project.swift b/Projects/Presentation/Profile/Project.swift index d7016d75..e4bdc61b 100644 --- a/Projects/Presentation/Profile/Project.swift +++ b/Projects/Presentation/Profile/Project.swift @@ -14,8 +14,9 @@ let project = Project.makeAppModule( .Shared(implements: .Shared), .SPM.composableArchitecture, .SPM.tcaFlow, + .SPM.googleMobileAds, .Presentation(implements: .Web), - .Presentation(implements: .Notification) + .Presentation(implements: .Notification), ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Profile/Sources/Ad/RewardedAdClient.swift b/Projects/Presentation/Profile/Sources/Ad/RewardedAdClient.swift new file mode 100644 index 00000000..6146a068 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Ad/RewardedAdClient.swift @@ -0,0 +1,110 @@ +// +// RewardedAdClient.swift +// Profile +// +// 무료 충전 — GoogleMobileAds 리워드 동영상 광고(네이티브). +// 광고 클릭 시 랜딩(웹뷰)은 SDK 인앱 브라우저로 자동 처리되며, +// rootViewController 만 올바르게 넘겨주면 된다. +// + +import Foundation + +import ComposableArchitecture +import LogMacro + +import GoogleMobileAds +import UIKit + +public struct RewardedAdClient: Sendable { + /// 리워드 광고를 로드/표시하고 보상 획득 여부를 반환. + public var showRewardedAd: @Sendable () async -> Bool + + public init(showRewardedAd: @escaping @Sendable () async -> Bool) { + self.showRewardedAd = showRewardedAd + } +} + +extension RewardedAdClient: DependencyKey { + public static let liveValue = RewardedAdClient( + showRewardedAd: { await RewardedAdPresenter().present() } + ) + + public static let testValue = RewardedAdClient(showRewardedAd: { false }) + public static let previewValue = testValue +} + +public extension DependencyValues { + var rewardedAdClient: RewardedAdClient { + get { self[RewardedAdClient.self] } + set { self[RewardedAdClient.self] = newValue } + } +} + +/// 리워드 광고 1회 표시를 책임지는 프레젠터. 표시 종료(보상/닫힘/실패)까지 self 를 유지한다. +@MainActor +private final class RewardedAdPresenter: NSObject, FullScreenContentDelegate { + // TODO: 실제 발급된 리워드 광고 유닛 ID 로 교체 (현재 Google 테스트 ID). + private let adUnitID = "ca-app-pub-3940256099942544/1712485313" + + private var rewardedAd: RewardedAd? + private var continuation: CheckedContinuation? + private var earnedReward = false + private var retainSelf: RewardedAdPresenter? + + func present() async -> Bool { + do { + let ad = try await RewardedAd.load(with: adUnitID, request: Request()) + rewardedAd = ad + ad.fullScreenContentDelegate = self + + guard let rootViewController = Self.topViewController() else { + Log.error("[RewardedAd] rootViewController 를 찾지 못했습니다") + return false + } + + return await withCheckedContinuation { continuation in + self.continuation = continuation + self.retainSelf = self + ad.present(from: rootViewController) { [weak self] in + self?.earnedReward = true + Log.debug("[RewardedAd] 보상 획득") + } + } + } catch { + Log.error("[RewardedAd] 광고 로드 실패: \(error.localizedDescription)") + return false + } + } + + // MARK: FullScreenContentDelegate + + func adDidDismissFullScreenContent(_: FullScreenPresentingAd) { + finish(earnedReward) + } + + func ad(_: FullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) { + Log.error("[RewardedAd] 광고 표시 실패: \(error.localizedDescription)") + finish(false) + } + + private func finish(_ result: Bool) { + continuation?.resume(returning: result) + continuation = nil + rewardedAd = nil + retainSelf = nil + } + + /// 현재 화면 최상단(모달 포함) ViewController — 광고/클릭 랜딩 표시 기준. + private static func topViewController() -> UIViewController? { + let keyWindow = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first(where: \.isKeyWindow) + + var top = keyWindow?.rootViewController + while let presented = top?.presentedViewController { + top = presented + } + return top + } +} diff --git a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift index 909039fa..b3f8c2a4 100644 --- a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift +++ b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift @@ -80,6 +80,7 @@ public struct ProfileFeature { case settingsTapped case profileTapped case chargePointTapped + case freeChargeTapped case philosopherTapped case menuTapped(MenuItem) } @@ -114,6 +115,7 @@ public struct ProfileFeature { } @Dependency(\.profileUseCase) private var profileUseCase + @Dependency(\.rewardedAdClient) private var rewardedAdClient public var body: some Reducer { BindingReducer() @@ -162,6 +164,15 @@ extension ProfileFeature { case .chargePointTapped: return .send(.delegate(.chargePoint)) + case .freeChargeTapped: + // 무료 충전 → 리워드 광고 표시, 보상 획득 시 포인트 갱신. + return .run { [rewardedAdClient] send in + let earned = await rewardedAdClient.showRewardedAd() + if earned { + await send(.async(.fetchProfile)) + } + } + case .philosopherTapped: return .send(.delegate(.openPhilosopher)) diff --git a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift index 2161da5f..e97c8e03 100644 --- a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift +++ b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift @@ -136,38 +136,48 @@ private extension ProfileView { @ViewBuilder func chargeButton() -> some View { - Button { - send(.chargePointTapped) - } label: { - HStack(spacing: 6) { - // 포인트 뱃지 + 보유 포인트 - ZStack { - Circle().fill(.secondary300) - Text("P") - .pretendardFont(family: .Bold, size: 11) - .foregroundStyle(.gray800) - } - .frame(width: 24, height: 24) + HStack(spacing: 6) { + // 좌측(포인트 뱃지 + 보유 포인트) → 포인트 내역 상세 + Button { + send(.chargePointTapped) + } label: { + HStack(spacing: 6) { + ZStack { + Circle().fill(.secondary300) + Text("P") + .pretendardFont(family: .Bold, size: 11) + .foregroundStyle(.gray800) + } + .frame(width: 24, height: 24) - Text("내 포인트 \(store.point)") - .pretendardFont(family: .SemiBold, size: 12) - .foregroundStyle(.beige50) + Text("내 포인트 \(store.point)") + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.beige50) - Spacer(minLength: 8) + Spacer(minLength: 8) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + // 무료 충전 → 리워드 광고 + Button { + send(.freeChargeTapped) + } label: { Text("무료 충전") .pretendardFont(family: .Medium, size: 11) .foregroundStyle(.gray800) .padding(.vertical, 4) .padding(.horizontal, 6) .background(.secondary300, in: RoundedRectangle(cornerRadius: 2)) + .contentShape(Rectangle()) } - .padding(.vertical, 20) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity) - .background(.primary800, in: RoundedRectangle(cornerRadius: 8)) + .buttonStyle(.plain) } - .buttonStyle(.plain) + .padding(.vertical, 20) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity) + .background(.primary800, in: RoundedRectangle(cornerRadius: 8)) } // MARK: 나의 철학자 유형 From 909f0e39556950f77b5f0397f269190f1d190a2d Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 22:57:28 +0900 Subject: [PATCH 35/39] =?UTF-8?q?feat:=20Mixpanel=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=97=B0=EA=B2=B0=20=ED=99=95=EC=9E=A5=20=E2=80=94?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20/=20=EB=B0=B0?= =?UTF-8?q?=ED=8B=80=20=EC=A1=B0=ED=9A=8C=C2=B7=ED=88=AC=ED=91=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettingsFeature: 로그아웃 완료 시 logout_succeeded 트래킹 - PreVoteFeature: 배틀 상세 진입(detailOpened), 사전/사후 투표 제출(prevote/postvoteSubmitted) 트래킹 --- .../Chat/Sources/Vote/Reducer/PreVoteFeature.swift | 4 ++++ .../Profile/Sources/Settings/Reducer/SettingsFeature.swift | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index 337a731f..4fc22e4d 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -119,6 +119,7 @@ public struct PreVoteFeature { @Dependency(\.battleUseCase) private var battleUseCase @Dependency(\.perspectiveUseCase) private var perspectiveUseCase + @Dependency(\.analyticsUseCase) private var analyticsUseCase public var body: some Reducer { BindingReducer() @@ -156,6 +157,7 @@ extension PreVoteFeature { ) -> Effect { switch action { case .onAppear: + analyticsUseCase.track(.battle(.detailOpened, BattleEventData(battleID: state.battleId))) var effects: [Effect] = [] if state.battleDetail == nil, state.battle == nil, !state.isLoading { effects.append(.send(.async(.fetchBattleDetail))) @@ -346,6 +348,7 @@ extension PreVoteFeature { state.isSubmitting = false switch result { case let .success(voteResult): + analyticsUseCase.track(.battle(.prevoteSubmitted, BattleEventData(battleID: state.battleId))) return .send(.delegate(.voteSubmitted(battleId: state.battleId, voteMode: .pre, result: voteResult))) case let .failure(error): Log.error("[PreVoteFeature] submitPreVote failed: \(error.localizedDescription)") @@ -360,6 +363,7 @@ extension PreVoteFeature { state.isSubmitting = false switch result { case let .success(voteResult): + analyticsUseCase.track(.battle(.postvoteSubmitted, BattleEventData(battleID: state.battleId))) return .send(.delegate(.voteSubmitted(battleId: state.battleId, voteMode: .post, result: voteResult))) case let .failure(error): // 최종투표는 1회만 가능 — 이미 투표한 경우 서버가 500. diff --git a/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift b/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift index 8169ea91..f3d8a7b8 100644 --- a/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift +++ b/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift @@ -90,6 +90,7 @@ public struct SettingsFeature { @Dependency(\.authUseCase) private var authUseCase @Dependency(\.keychainManager) private var keychainManager + @Dependency(\.analyticsUseCase) private var analyticsUseCase public var body: some Reducer { BindingReducer() @@ -157,12 +158,15 @@ extension SettingsFeature { case .performLogout: guard !state.isProcessing else { return .none } state.isProcessing = true - return .run { send in + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + let provider = userSession.provider.rawValue + return .run { [analyticsUseCase] send in do { _ = try await authUseCase.logout() } catch { Log.error("[SettingsFeature] logout failed: \(error.localizedDescription)") } + analyticsUseCase.track(.session(.logoutSucceeded, SessionEventData(provider: provider))) await send(.inner(.sessionCleared)) } .cancellable(id: CancelID.auth, cancelInFlight: true) From 9c92602c1bf10c5d3e7ad20289e46485f675f667 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 23:14:27 +0900 Subject: [PATCH 36/39] =?UTF-8?q?chore:=20=EB=B9=8C=EB=93=9C=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=2022=20=E2=86=92=2023?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Project+Templete/Extension+String.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift index d6792e1d..796f38d4 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift @@ -17,7 +17,7 @@ extension String { return Project.Environment.bundlePrefix } - public static func appBuildVersion(buildVersion: String = "22") -> String { + public static func appBuildVersion(buildVersion: String = "23") -> String { return buildVersion } From e5812b3ddb340b339af08ecfb43e527d94a92791 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 23:27:04 +0900 Subject: [PATCH 37/39] =?UTF-8?q?refactor:=20=EB=A6=AC=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=20=EA=B4=91=EA=B3=A0=20=EC=9C=A0=EB=8B=9B=20ID=EB=A5=BC=20conf?= =?UTF-8?q?ig(REWARD=5FAD=5FUNIT)=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20+=20Rewa?= =?UTF-8?q?rdedAdClient=20UseCase=20=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - xcconfig(REWARD_AD_UNIT) → Info.plist → Bundle 주입, 미설정 시 테스트 ID 폴백 - RewardedAdClient 를 Profile → UseCase 모듈로 이동(AnalyticsUseCase 와 동일하게 side-effect 클라이언트 일원화) - Profile googleMobileAds 의존성 제거, UseCase 로 이관 --- .../infoPlist/InfoPlistDictionary.swift | 4 ++++ .../infoPlist/Project+InfoPlist.swift | 1 + Projects/Domain/UseCase/Project.swift | 1 + .../UseCase}/Sources/Ad/RewardedAdClient.swift | 11 ++++++++--- Projects/Presentation/Profile/Project.swift | 1 - 5 files changed, 14 insertions(+), 4 deletions(-) rename Projects/{Presentation/Profile => Domain/UseCase}/Sources/Ad/RewardedAdClient.swift (89%) diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift index 421fb37e..3539fcdc 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift @@ -211,6 +211,10 @@ extension InfoPlistDictionary { merging(["GADApplicationIdentifier": .string(value)]) { _, new in new } } + func setRewardAdUnit(_ value: String) -> InfoPlistDictionary { + merging(["REWARD_AD_UNIT": .string(value)]) { _, new in new } + } + func setSKAdNetworkItems(_ identifiers: [String]) -> InfoPlistDictionary { merging([ "SKAdNetworkItems": .array( diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift index 2092c155..cee672bf 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift @@ -48,6 +48,7 @@ public extension InfoPlist { .setGIDClientID("${GOOGLE_CLIENT_ID}") .setAdmobToken("${ADMOB_TOKEN}") .setGADApplicationId("${ADMOB_TOKEN}") + .setRewardAdUnit("$(REWARD_AD_UNIT)") .setKakaoRestApiKey() .setLSApplicationQueriesSchemes([ "kakaokompassauth", // 카카오톡 로그인 diff --git a/Projects/Domain/UseCase/Project.swift b/Projects/Domain/UseCase/Project.swift index af1b0fbb..495b88e1 100644 --- a/Projects/Domain/UseCase/Project.swift +++ b/Projects/Domain/UseCase/Project.swift @@ -15,6 +15,7 @@ let project = Project.makeModule( .SPM.weaveDI, .SPM.mixpanel, .SPM.mixpanelSessionReplay, + .SPM.googleMobileAds, ], sources: ["Sources/**"], hasTests: true diff --git a/Projects/Presentation/Profile/Sources/Ad/RewardedAdClient.swift b/Projects/Domain/UseCase/Sources/Ad/RewardedAdClient.swift similarity index 89% rename from Projects/Presentation/Profile/Sources/Ad/RewardedAdClient.swift rename to Projects/Domain/UseCase/Sources/Ad/RewardedAdClient.swift index 6146a068..cf8ac68c 100644 --- a/Projects/Presentation/Profile/Sources/Ad/RewardedAdClient.swift +++ b/Projects/Domain/UseCase/Sources/Ad/RewardedAdClient.swift @@ -1,6 +1,6 @@ // // RewardedAdClient.swift -// Profile +// UseCase // // 무료 충전 — GoogleMobileAds 리워드 동영상 광고(네이티브). // 광고 클릭 시 랜딩(웹뷰)은 SDK 인앱 브라우저로 자동 처리되며, @@ -43,8 +43,13 @@ public extension DependencyValues { /// 리워드 광고 1회 표시를 책임지는 프레젠터. 표시 종료(보상/닫힘/실패)까지 self 를 유지한다. @MainActor private final class RewardedAdPresenter: NSObject, FullScreenContentDelegate { - // TODO: 실제 발급된 리워드 광고 유닛 ID 로 교체 (현재 Google 테스트 ID). - private let adUnitID = "ca-app-pub-3940256099942544/1712485313" + // 리워드 광고 유닛 ID — xcconfig(REWARD_AD_UNIT) → Info.plist → Bundle 에서 주입. + // 값이 없으면 Google 테스트 ID 로 폴백. + private let adUnitID: String = { + let key = Bundle.main.object(forInfoDictionaryKey: "REWARD_AD_UNIT") as? String + if let key, !key.isEmpty { return key } + return "ca-app-pub-3940256099942544/1712485313" + }() private var rewardedAd: RewardedAd? private var continuation: CheckedContinuation? diff --git a/Projects/Presentation/Profile/Project.swift b/Projects/Presentation/Profile/Project.swift index e4bdc61b..eac5717d 100644 --- a/Projects/Presentation/Profile/Project.swift +++ b/Projects/Presentation/Profile/Project.swift @@ -14,7 +14,6 @@ let project = Project.makeAppModule( .Shared(implements: .Shared), .SPM.composableArchitecture, .SPM.tcaFlow, - .SPM.googleMobileAds, .Presentation(implements: .Web), .Presentation(implements: .Notification), ], From 332c0dc103952889b7deef4f4ba8a5900163981f Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 23:47:49 +0900 Subject: [PATCH 38/39] =?UTF-8?q?refactor:=20Mixpanel=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20PICK=C3=A9=20=ED=95=B5=EC=8B=AC=20?= =?UTF-8?q?=EB=AA=85=EC=84=B8=EC=84=9C=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=AC=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이벤트 통합(분리 금지): sign_up / battle_step(step_name) / report_action / community_action / ad_revenue - identify(userTag) 로그인 성공 직후 연결, sign_up(method)은 신규 가입 시 - battle_step: pre_vote/post_vote(PreVote), audio_end(ChatRoom) - report_action: recap 조회(view)/공유(share) + top_indicator - community_action: 댓글 등록 성공 시 content_id/comment_length - ad_revenue: 리워드 광고 시청 완료(placement: 충전소) - 명세 외 이벤트 제거(login_failed/logout 등 — 로그아웃은 BE 담당) fix: BattleView 빈 배틀 안내 오타(선정되지않았어요), ProfileTests import 오타(rofile→Profile) --- .../Sources/Analytics/AnalyticsUseCase.swift | 285 +++++++----------- .../Sources/Main/Reducer/LoginFeature.swift | 26 +- .../Battle/Sources/Main/View/BattleView.swift | 2 +- .../ChatRoom/Reducer/ChatRoomFeature.swift | 4 + .../Comment/Reducer/CommentFeature.swift | 8 +- .../Sources/Vote/Reducer/PreVoteFeature.swift | 9 +- .../Sources/Main/Reducer/ProfileFeature.swift | 6 +- .../Sources/Recap/Reducer/RecapFeature.swift | 7 + .../Settings/Reducer/SettingsFeature.swift | 6 +- .../Profile/Tests/Sources/ProfileTests.swift | 33 +- 10 files changed, 165 insertions(+), 221 deletions(-) diff --git a/Projects/Domain/UseCase/Sources/Analytics/AnalyticsUseCase.swift b/Projects/Domain/UseCase/Sources/Analytics/AnalyticsUseCase.swift index f3c121d3..429a15e9 100644 --- a/Projects/Domain/UseCase/Sources/Analytics/AnalyticsUseCase.swift +++ b/Projects/Domain/UseCase/Sources/Analytics/AnalyticsUseCase.swift @@ -2,8 +2,8 @@ // AnalyticsUseCase.swift // UseCase // -// Mixpanel 이벤트 트래킹 — 타입 안전한 이벤트 + 공통 유저 프로퍼티. -// (TimeSpot-iOS AnalyticsUseCase 패턴을 Picke 도메인에 맞춰 적용) +// Mixpanel 트래킹 — PICKé 핵심 이벤트 명세서 기준. +// 이벤트를 쪼개지 않고 하나의 카테고리 + 속성값으로 구분(무료 플랜 한도 절약). // import Foundation @@ -14,217 +14,164 @@ import LogMacro import Mixpanel import MixpanelSessionReplay -// MARK: - 이벤트 정의 +// MARK: - 이벤트 정의 (명세서) public enum AnalyticsEvent: Sendable { - case auth(AuthEventType, AuthEventData) - case battle(BattleEventType, BattleEventData) - case session(SessionEventType, SessionEventData) + /// 소셜 가입 완료 및 메인 진입 시. + case signUp(method: String) + /// 배틀 각 단계 완료 시 (pre_vote / audio_end / post_vote). + case battleStep(BattleStepData) + /// 리포트 조회/공유 클릭 시. + case reportAction(ReportActionData) + /// 댓글 등록 성공 시. + case communityAction(CommunityActionData) + /// 보상형 광고 시청 완료 시. + case adRevenue(placement: String) } -public enum AuthEventType: String, Sendable { - case loginSucceeded = "login_succeeded" - case loginFailed = "login_failed" - case signupSucceeded = "signup_succeeded" - case signupFailed = "signup_failed" +public enum BattleStep: String, Sendable { + case preVote = "pre_vote" + case audioEnd = "audio_end" + case postVote = "post_vote" } -public struct AuthEventData: Sendable { - public let username: String? - public let userTag: String? - public let socialType: String? - public let isNewUser: Bool? - public let errorDescription: String? +public struct BattleStepData: Sendable { + public let stepName: BattleStep + public let contentID: String + /// 선택지(좌/우 등). 없으면 미전송. + public let choice: String? + /// 사전→사후 투표 변경 여부. post_vote 에서만 유의미. + public let isChanged: Bool? public init( - username: String? = nil, - userTag: String? = nil, - socialType: String? = nil, - isNewUser: Bool? = nil, - errorDescription: String? = nil + stepName: BattleStep, + contentID: String, + choice: String? = nil, + isChanged: Bool? = nil ) { - self.username = username - self.userTag = userTag - self.socialType = socialType - self.isNewUser = isNewUser - self.errorDescription = errorDescription + self.stepName = stepName + self.contentID = contentID + self.choice = choice + self.isChanged = isChanged } } -public enum BattleEventType: String, Sendable { - case viewed = "battle_viewed" - case detailOpened = "battle_detail_opened" - case prevoteSubmitted = "battle_prevote_submitted" - case postvoteSubmitted = "battle_postvote_submitted" +public enum ReportActionType: String, Sendable { + case view + case share } -public struct BattleEventData: Sendable { - public let battleID: Int? - public let battleTitle: String? - public let voteSide: String? - public let source: String? +public struct ReportActionData: Sendable { + public let actionType: ReportActionType + /// 대표 지표(최상위 철학자 유형 등). 없으면 미전송. + public let topIndicator: String? - public init( - battleID: Int? = nil, - battleTitle: String? = nil, - voteSide: String? = nil, - source: String? = nil - ) { - self.battleID = battleID - self.battleTitle = battleTitle - self.voteSide = voteSide - self.source = source + public init(actionType: ReportActionType, topIndicator: String? = nil) { + self.actionType = actionType + self.topIndicator = topIndicator } } -public enum SessionEventType: String, Sendable { - case logoutSucceeded = "logout_succeeded" -} +public struct CommunityActionData: Sendable { + public let contentID: String + public let commentLength: Int -public struct SessionEventData: Sendable { - public let provider: String - - public init(provider: String) { - self.provider = provider + public init(contentID: String, commentLength: Int) { + self.contentID = contentID + self.commentLength = commentLength } } // MARK: - UseCase public struct AnalyticsUseCase: Sendable { + /// 로그인 성공 직후 유저 고유 ID 를 Mixpanel 에 연결. + public var identify: @Sendable (_ userID: String, _ method: String?) -> Void + /// 핵심 퍼널 이벤트 트래킹. public var track: @Sendable (_ event: AnalyticsEvent) -> Void - public init(track: @escaping @Sendable (_ event: AnalyticsEvent) -> Void) { + public init( + identify: @escaping @Sendable (_ userID: String, _ method: String?) -> Void, + track: @escaping @Sendable (_ event: AnalyticsEvent) -> Void + ) { + self.identify = identify self.track = track } } extension AnalyticsUseCase: DependencyKey { - public static let liveValue = AnalyticsUseCase { event in - let mixpanel = Mixpanel.mainInstance() - let userSession = currentUserSession() + public static let liveValue = AnalyticsUseCase( + identify: { userID, method in + guard !userID.isEmpty else { return } - switch event { - case let .auth(type, data): - if type == .loginSucceeded || type == .signupSucceeded { - identifyIfPossible( - mixpanel: mixpanel, - userTag: data.userTag, - socialType: data.socialType, - isNewUser: data.isNewUser, - username: data.username - ) + let mixpanel = Mixpanel.mainInstance() + mixpanel.identify(distinctId: userID) + MPSessionReplay.getInstance()?.identify(distinctId: userID) + + var properties: Properties = [:] + if let method, !method.isEmpty { + properties["provider"] = method } - let properties = authProperties(data, userSession: userSession) - #logDebug("Mixpanel track", ["event": type.rawValue, "properties": String(describing: properties)]) - mixpanel.track(event: type.rawValue, properties: properties) - - case let .battle(type, data): - let properties = battleProperties(data, userSession: userSession) - #logDebug("Mixpanel track", ["event": type.rawValue, "properties": String(describing: properties)]) - mixpanel.track(event: type.rawValue, properties: properties) - - case let .session(type, data): - let properties = sessionProperties(data, userSession: userSession) - #logDebug("Mixpanel track", ["event": type.rawValue, "properties": String(describing: properties)]) - mixpanel.track(event: type.rawValue, properties: properties) - if type == .logoutSucceeded { - mixpanel.reset() - MPSessionReplay.getInstance()?.identify(distinctId: mixpanel.distinctId) + if !properties.isEmpty { + mixpanel.people.set(properties: properties) } + }, + track: { event in + let mixpanel = Mixpanel.mainInstance() + let name = eventName(event) + let properties = eventProperties(event) + #logDebug("Mixpanel track", ["event": name, "properties": String(describing: properties)]) + mixpanel.track(event: name, properties: properties) } - } + ) - public static let testValue = AnalyticsUseCase { _ in } + public static let testValue = AnalyticsUseCase(identify: { _, _ in }, track: { _ in }) public static let previewValue = testValue - private static func identifyIfPossible( - mixpanel: MixpanelInstance, - userTag: String?, - socialType: String?, - isNewUser: Bool?, - username: String? - ) { - let distinctID = userTag?.nilIfEmpty - ?? username?.nilIfEmpty - ?? "\(socialType ?? "unknown")-\(UUID().uuidString)" - mixpanel.identify(distinctId: distinctID) - MPSessionReplay.getInstance()?.identify(distinctId: distinctID) - - var properties: Properties = [:] - if let socialType { - properties["provider"] = socialType - } - if let isNewUser { - properties["is_new_user"] = isNewUser - } - if let username, !username.isEmpty { - properties["$name"] = username - properties["username"] = username - } - if let userTag, !userTag.isEmpty { - properties["user_tag"] = userTag - } - - guard !properties.isEmpty else { return } - mixpanel.people.set(properties: properties) - } - - private static func authProperties(_ data: AuthEventData, userSession: UserSession) -> Properties { - var properties = commonUserProperties(userSession: userSession) - if let username = data.username { - properties["username"] = username - } - if let userTag = data.userTag { - properties["user_tag"] = userTag - } - if let socialType = data.socialType { - properties["social_type"] = socialType - } - if let isNewUser = data.isNewUser { - properties["is_new_user"] = isNewUser - } - if let errorDescription = data.errorDescription { - properties["error_description"] = errorDescription + private static func eventName(_ event: AnalyticsEvent) -> String { + switch event { + case .signUp: "sign_up" + case .battleStep: "battle_step" + case .reportAction: "report_action" + case .communityAction: "community_action" + case .adRevenue: "ad_revenue" } - return properties } - private static func battleProperties(_ data: BattleEventData, userSession: UserSession) -> Properties { - var properties = commonUserProperties(userSession: userSession) - if let battleID = data.battleID { - properties["battle_id"] = battleID - } - if let battleTitle = data.battleTitle { - properties["battle_title"] = battleTitle - } - if let voteSide = data.voteSide { - properties["vote_side"] = voteSide - } - if let source = data.source { - properties["source"] = source - } - return properties - } + private static func eventProperties(_ event: AnalyticsEvent) -> Properties { + switch event { + case let .signUp(method): + return ["method": method] + + case let .battleStep(data): + var properties: Properties = [ + "step_name": data.stepName.rawValue, + "content_id": data.contentID, + ] + if let choice = data.choice { + properties["choice"] = choice + } + if let isChanged = data.isChanged { + properties["is_changed"] = isChanged + } + return properties - private static func sessionProperties(_ data: SessionEventData, userSession: UserSession) -> Properties { - var properties = commonUserProperties(userSession: userSession) - properties["provider"] = data.provider - return properties - } + case let .reportAction(data): + var properties: Properties = ["action_type": data.actionType.rawValue] + if let topIndicator = data.topIndicator { + properties["top_indicator"] = topIndicator + } + return properties - private static func currentUserSession() -> UserSession { - @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty - return userSession - } + case let .communityAction(data): + return [ + "content_id": data.contentID, + "comment_length": data.commentLength, + ] - private static func commonUserProperties(userSession: UserSession) -> Properties { - var properties: Properties = [:] - if !userSession.name.isEmpty { - properties["username"] = userSession.name + case let .adRevenue(placement): + return ["placement": placement] } - properties["provider"] = userSession.provider.rawValue - return properties } } @@ -234,7 +181,3 @@ public extension DependencyValues { set { self[AnalyticsUseCase.self] = newValue } } } - -private extension String { - var nilIfEmpty: String? { isEmpty ? nil : self } -} diff --git a/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift b/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift index 0d7a92ec..7b26addf 100644 --- a/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift +++ b/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift @@ -199,17 +199,12 @@ extension LoginFeature { case let .success(loginEntity): state.loginEntity = loginEntity - analyticsUseCase.track( - .auth( - loginEntity.isNewUser ? .signupSucceeded : .loginSucceeded, - AuthEventData( - username: loginEntity.name, - userTag: loginEntity.userTag, - socialType: loginEntity.provider.rawValue, - isNewUser: loginEntity.isNewUser - ) - ) - ) + // 로그인 성공 직후 유저 고유 ID(userTag) 를 Mixpanel 에 연결. + analyticsUseCase.identify(loginEntity.userTag, loginEntity.provider.rawValue) + // 신규 가입(메인 진입) 시 sign_up. + if loginEntity.isNewUser { + analyticsUseCase.track(.signUp(method: loginEntity.provider.rawValue)) + } guard loginEntity.isNewUser else { return .send(.delegate(.presentMainTab)) @@ -224,15 +219,6 @@ extension LoginFeature { case let .failure(error): #logNetwork("로그인 실패", error.localizedDescription) let socialType = state.currentSocialType - analyticsUseCase.track( - .auth( - .loginFailed, - AuthEventData( - socialType: socialType?.rawValue, - errorDescription: error.errorDescription ?? error.localizedDescription - ) - ) - ) return .run { _ in await MainActor.run { let errorMessage = switch socialType { diff --git a/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift b/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift index dcb53f4d..38039b24 100644 --- a/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift +++ b/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift @@ -339,7 +339,7 @@ private extension BattleView { .scaledToFit() .frame(width: 135, height: 90) - Text("아직 빠른 배틀이 선정되지않았어요\n 조금만 기다려주세요!") + Text("아직 빠른 배틀이 선정되지 않았어요\n 조금만 기다려주세요!") .pretendardCustomFont(textStyle: .bodyMedium) .foregroundStyle(.beige300) } diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index f1f3a85b..074e43da 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -221,6 +221,7 @@ public struct ChatRoomFeature { @Dependency(\.battleUseCase) private var battleUseCase @Dependency(\.audioPlayer) private var audioPlayer + @Dependency(\.analyticsUseCase) private var analyticsUseCase public var body: some Reducer { BindingReducer() @@ -486,6 +487,9 @@ extension ChatRoomFeature { if !state.hasFinishedListening { state.hasFinishedListening = true UserDefaults.standard.set(true, forKey: State.listenedKey(battleId: state.battleId)) + analyticsUseCase.track( + .battleStep(BattleStepData(stepName: .audioEnd, contentID: "\(state.battleId)")) + ) } state.isPlaying = false if !state.hasPresentedFinalVoteAlert { diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index 6b55b8d1..fff51523 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -155,6 +155,7 @@ public struct CommentFeature { @Dependency(\.battleUseCase) private var battleUseCase @Dependency(\.commentUseCase) private var commentUseCase @Dependency(\.perspectiveUseCase) private var perspectiveUseCase + @Dependency(\.analyticsUseCase) private var analyticsUseCase public var body: some Reducer { BindingReducer() @@ -427,11 +428,16 @@ extension CommentFeature { } }() // 관점 등록: POST /battles/{id}/perspectives, body {content, optionId} - return .run { [battle = battleUseCase] send in + return .run { [battle = battleUseCase, analyticsUseCase] send in let result = await Result { try await battle.createPerspective(battleId: battleId, content: content, optionId: optionId) } .mapError(BattleError.from) + if case .success = result { + analyticsUseCase.track( + .communityAction(CommunityActionData(contentID: "\(battleId)", commentLength: content.count)) + ) + } return await send(.inner(.createCommentResponse(result))) } .cancellable(id: CancelID.createComment, cancelInFlight: false) diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index 4fc22e4d..d9a2b17d 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -157,7 +157,6 @@ extension PreVoteFeature { ) -> Effect { switch action { case .onAppear: - analyticsUseCase.track(.battle(.detailOpened, BattleEventData(battleID: state.battleId))) var effects: [Effect] = [] if state.battleDetail == nil, state.battle == nil, !state.isLoading { effects.append(.send(.async(.fetchBattleDetail))) @@ -348,7 +347,9 @@ extension PreVoteFeature { state.isSubmitting = false switch result { case let .success(voteResult): - analyticsUseCase.track(.battle(.prevoteSubmitted, BattleEventData(battleID: state.battleId))) + analyticsUseCase.track( + .battleStep(BattleStepData(stepName: .preVote, contentID: "\(state.battleId)")) + ) return .send(.delegate(.voteSubmitted(battleId: state.battleId, voteMode: .pre, result: voteResult))) case let .failure(error): Log.error("[PreVoteFeature] submitPreVote failed: \(error.localizedDescription)") @@ -363,7 +364,9 @@ extension PreVoteFeature { state.isSubmitting = false switch result { case let .success(voteResult): - analyticsUseCase.track(.battle(.postvoteSubmitted, BattleEventData(battleID: state.battleId))) + analyticsUseCase.track( + .battleStep(BattleStepData(stepName: .postVote, contentID: "\(state.battleId)")) + ) return .send(.delegate(.voteSubmitted(battleId: state.battleId, voteMode: .post, result: voteResult))) case let .failure(error): // 최종투표는 1회만 가능 — 이미 투표한 경우 서버가 500. diff --git a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift index b3f8c2a4..1e7c31b2 100644 --- a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift +++ b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift @@ -116,6 +116,7 @@ public struct ProfileFeature { @Dependency(\.profileUseCase) private var profileUseCase @Dependency(\.rewardedAdClient) private var rewardedAdClient + @Dependency(\.analyticsUseCase) private var analyticsUseCase public var body: some Reducer { BindingReducer() @@ -165,10 +166,11 @@ extension ProfileFeature { return .send(.delegate(.chargePoint)) case .freeChargeTapped: - // 무료 충전 → 리워드 광고 표시, 보상 획득 시 포인트 갱신. - return .run { [rewardedAdClient] send in + // 무료 충전 → 리워드 광고 표시, 보상 획득 시 ad_revenue 트래킹 + 포인트 갱신. + return .run { [rewardedAdClient, analyticsUseCase] send in let earned = await rewardedAdClient.showRewardedAd() if earned { + analyticsUseCase.track(.adRevenue(placement: "충전소")) await send(.async(.fetchProfile)) } } diff --git a/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift b/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift index 92f15e25..a979dd5e 100644 --- a/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift +++ b/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift @@ -69,6 +69,7 @@ public struct RecapFeature { } @Dependency(\.profileUseCase) private var profileUseCase + @Dependency(\.analyticsUseCase) private var analyticsUseCase public var body: some Reducer { BindingReducer() @@ -120,6 +121,9 @@ extension RecapFeature { items.append(url) } state.shareItem = ShareItem(items: items) + analyticsUseCase.track( + .reportAction(ReportActionData(actionType: .share, topIndicator: recap.myCard.typeName)) + ) return .none } } @@ -152,6 +156,9 @@ extension RecapFeature { switch result { case let .success(recap): state.recap = recap + analyticsUseCase.track( + .reportAction(ReportActionData(actionType: .view, topIndicator: recap.myCard.typeName)) + ) case let .failure(error): Log.error("[RecapFeature] fetchRecap failed: \(error.localizedDescription)") } diff --git a/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift b/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift index f3d8a7b8..8169ea91 100644 --- a/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift +++ b/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift @@ -90,7 +90,6 @@ public struct SettingsFeature { @Dependency(\.authUseCase) private var authUseCase @Dependency(\.keychainManager) private var keychainManager - @Dependency(\.analyticsUseCase) private var analyticsUseCase public var body: some Reducer { BindingReducer() @@ -158,15 +157,12 @@ extension SettingsFeature { case .performLogout: guard !state.isProcessing else { return .none } state.isProcessing = true - @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty - let provider = userSession.provider.rawValue - return .run { [analyticsUseCase] send in + return .run { send in do { _ = try await authUseCase.logout() } catch { Log.error("[SettingsFeature] logout failed: \(error.localizedDescription)") } - analyticsUseCase.track(.session(.logoutSucceeded, SessionEventData(provider: provider))) await send(.inner(.sessionCleared)) } .cancellable(id: CancelID.auth, cancelInFlight: true) diff --git a/Projects/Presentation/Profile/Tests/Sources/ProfileTests.swift b/Projects/Presentation/Profile/Tests/Sources/ProfileTests.swift index 67e89aef..f3758250 100644 --- a/Projects/Presentation/Profile/Tests/Sources/ProfileTests.swift +++ b/Projects/Presentation/Profile/Tests/Sources/ProfileTests.swift @@ -1,27 +1,24 @@ // -// rofileTests.swift -// Presentation.rofileTests +// ProfileTests.swift +// Presentation.ProfileTests // // Created by Roy on 2026-06-08. // +@testable import Profile import Testing -@testable import rofile -struct rofileTests { - - @Test - func rofileExample() { - // This is an example of a test case. - #expect(true) - } - - @Test - func rofileLogicTest() { - // Add your test logic here. - let result = true - #expect(result == true) - } +struct ProfileTests { + @Test + func profileExample() { + // This is an example of a test case. + #expect(true) + } + @Test + func profileLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } } - From b7e22a9ce1e33aa39fac2c0b699c0b92f05941ab Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 10 Jun 2026 23:53:34 +0900 Subject: [PATCH 39/39] =?UTF-8?q?style:=20swiftformat=20=EC=9D=BC=EA=B4=84?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20=E2=80=94=20PR=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=ED=8F=AC=EB=A7=B7=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TargetDependency+Module/Modules.swift | 21 +++++++-------- .../Project+Templete/Extension+String.swift | 19 +++++++------ Projects/Presentation/Home/Project.swift | 2 +- Projects/Presentation/MainTab/Project.swift | 6 ++--- .../Presentation/Notification/Project.swift | 6 ++--- .../Notification/Sources/Base.swift | 19 +++++++------ .../Tests/Sources/NotificationTests.swift | 27 +++++++++---------- .../Sources/Image/ImageAsset.swift | 5 ++-- 8 files changed, 49 insertions(+), 56 deletions(-) diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index b8dae1b4..d2c122e1 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -17,6 +17,7 @@ public enum ModulePath { } // MARK: FeatureModule + public extension ModulePath { enum Presentations: String, CaseIterable { case Presentation @@ -31,14 +32,13 @@ public extension ModulePath { case Profile public static let name: String = "Presentation" - - case Notification + + case Notification } } +// MARK: - CoreDomainModule - -//MARK: - CoreDomainModule public extension ModulePath { enum Networks: String, CaseIterable { case Networking @@ -49,7 +49,8 @@ public extension ModulePath { } } -//MARK: - CoreMoudule +// MARK: - CoreMoudule + public extension ModulePath { enum Datas: String, CaseIterable { case Model @@ -62,8 +63,8 @@ public extension ModulePath { } } +// MARK: - CoreMoudule -//MARK: - CoreMoudule public extension ModulePath { enum Domains: String, CaseIterable { case Entity @@ -72,21 +73,17 @@ public extension ModulePath { case DataInterface case DomainInterface - public static let name: String = "Domain" } } - public extension ModulePath { enum Shareds: String, CaseIterable { case Shared case DesignSystem case Utill - + public static let name: String = "Shared" - case ThirdParty + case ThirdParty } } - - diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift index 796f38d4..4c5ab335 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift @@ -8,21 +8,20 @@ import Foundation import ProjectDescription -extension String { - public static func appVersion(version: String = "1.0.0") -> String { +public extension String { + static func appVersion(version: String = "1.0.0") -> String { return version } - - public static func mainBundleID() -> String { + + static func mainBundleID() -> String { return Project.Environment.bundlePrefix } - - public static func appBuildVersion(buildVersion: String = "23") -> String { + + static func appBuildVersion(buildVersion: String = "23") -> String { return buildVersion } - - public static func appBundleID(name: String) -> String { - return Project.Environment.bundlePrefix+name + + static func appBundleID(name: String) -> String { + return Project.Environment.bundlePrefix + name } - } diff --git a/Projects/Presentation/Home/Project.swift b/Projects/Presentation/Home/Project.swift index 9c62302a..40ac9406 100644 --- a/Projects/Presentation/Home/Project.swift +++ b/Projects/Presentation/Home/Project.swift @@ -15,7 +15,7 @@ let project = Project.makeAppModule( .Domain(implements: .UseCase), .Shared(implements: .Shared), .Presentation(implements: .Chat), - .Presentation(implements: .Notification) + .Presentation(implements: .Notification), ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/MainTab/Project.swift b/Projects/Presentation/MainTab/Project.swift index 188eb59f..c3127fba 100644 --- a/Projects/Presentation/MainTab/Project.swift +++ b/Projects/Presentation/MainTab/Project.swift @@ -1,8 +1,8 @@ +import DependencyPackagePlugin +import DependencyPlugin import Foundation import ProjectDescription -import DependencyPlugin import ProjectTemplatePlugin -import DependencyPackagePlugin let project = Project.makeAppModule( name: "MainTab", @@ -17,7 +17,7 @@ let project = Project.makeAppModule( .Presentation(implements: .Home), .Presentation(implements: .Hifi), .Presentation(implements: .Battle), - .Presentation(implements: .Profile) + .Presentation(implements: .Profile), ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Notification/Project.swift b/Projects/Presentation/Notification/Project.swift index 3f0b0df4..6bf31e4d 100644 --- a/Projects/Presentation/Notification/Project.swift +++ b/Projects/Presentation/Notification/Project.swift @@ -1,8 +1,8 @@ +import DependencyPackagePlugin +import DependencyPlugin import Foundation import ProjectDescription -import DependencyPlugin import ProjectTemplatePlugin -import DependencyPackagePlugin let project = Project.makeAppModule( name: "Notification", @@ -16,4 +16,4 @@ let project = Project.makeAppModule( .SPM.tcaFlow, ], sources: ["Sources/**"] -) \ No newline at end of file +) diff --git a/Projects/Presentation/Notification/Sources/Base.swift b/Projects/Presentation/Notification/Sources/Base.swift index 2c8db71a..e2ae3503 100644 --- a/Projects/Presentation/Notification/Sources/Base.swift +++ b/Projects/Presentation/Notification/Sources/Base.swift @@ -1,5 +1,5 @@ // -// base.swift +// Base.swift // DDDAttendance. // // Created by Roy on 2026-06-10 @@ -9,14 +9,13 @@ import SwiftUI struct BaseView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("Hello, world!") - } - .padding() + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundColor(.accentColor) + Text("Hello, world!") } + .padding() + } } - diff --git a/Projects/Presentation/Notification/Tests/Sources/NotificationTests.swift b/Projects/Presentation/Notification/Tests/Sources/NotificationTests.swift index 9b7d6fee..7d4136bf 100644 --- a/Projects/Presentation/Notification/Tests/Sources/NotificationTests.swift +++ b/Projects/Presentation/Notification/Tests/Sources/NotificationTests.swift @@ -5,23 +5,20 @@ // Created by Roy on 2026-06-10. // -import Testing @testable import Notification +import Testing struct NotificationTests { + @Test + func notificationExample() { + // This is an example of a test case. + #expect(true) + } - @Test - func notificationExample() { - // This is an example of a test case. - #expect(true) - } - - @Test - func notificationLogicTest() { - // Add your test logic here. - let result = true - #expect(result == true) - } - + @Test + func notificationLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } } - diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index 278a5ddc..da75d399 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift @@ -50,8 +50,9 @@ public enum ImageAsset: String { case avatarSunja case none - - //MARK: - 프로필 + + // MARK: - 프로필 + case history case lock }