Skip to content

Commit c0cb451

Browse files
jihun32jihun
andauthored
feat: 통계 상세 api 연동 (#207)
* fix: 목표 비어있을 때 toEntity 처리 - #206 * fix: emptyView 깜빡이는 현상 해결 - #206 * fix: 종료된 목표 라이팅 수정 - #206 * feat: fetchStatsDetail(calendar/summary) Domain 구현 * feat: fetchStatsDetail View, Reducer 구현 - #206 * fix: 카드 스와이프 버그 수정 - #206 * feat: 통계 상세 드롭다운 기능 구현 - #206 * feat: subContent navibar 배경색 주입 - #206 * fix: EditGoalList, GoalDetail 처음에 emptyView 안보이도록 개선 - #206 * feat: StatsCardHeader 터치시 Detail 이동 구현 - #206 --------- Co-authored-by: jihun <jihun332@gmai.com>
1 parent 4abb2b4 commit c0cb451

File tree

22 files changed

+325
-119
lines changed

22 files changed

+325
-119
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//
2+
// StatsDetailCalendarResponseDTO.swift
3+
// DomainStatsInterface
4+
//
5+
// Created by 정지훈 on 2/25/26.
6+
//
7+
8+
import Foundation
9+
10+
public struct StatsDetailCalendarResponseDTO: Decodable {
11+
let goalId: Int64
12+
let goalName: String
13+
let goalIcon: String
14+
let yearMonth: String
15+
let isCompleted: Bool
16+
let completedDates: [CompletedDate]
17+
18+
struct CompletedDate: Decodable {
19+
let date: String
20+
let myImageUrl: String?
21+
let partnerImageUrl: String?
22+
}
23+
}
24+
25+
extension StatsDetailCalendarResponseDTO {
26+
public func toEntity() -> StatsDetail {
27+
StatsDetail(
28+
goalId: goalId,
29+
goalName: goalName,
30+
isCompleted: isCompleted,
31+
yearMonth: yearMonth,
32+
completedDate: completedDates.map {
33+
StatsDetail.CompletedDate(
34+
date: $0.date,
35+
myImageUrl: $0.myImageUrl,
36+
partnerImageUrl: $0.partnerImageUrl
37+
)
38+
}
39+
)
40+
}
41+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//
2+
// StatsDetailSummaryResponseDTO.swift
3+
// DomainStatsInterface
4+
//
5+
// Created by 정지훈 on 2/25/26.
6+
//
7+
8+
import Foundation
9+
10+
public struct StatsDetailSummaryResponseDTO: Decodable {
11+
let myNickname: String
12+
let partnerNickname: String
13+
let totalCount: Int
14+
let myCompletedCount: Int
15+
let partnerCompletedCount: Int
16+
let repeatCycle: String
17+
let startDate: String
18+
let endDate: String?
19+
}
20+
21+
extension StatsDetailSummaryResponseDTO {
22+
public func toEntity() -> StatsDetail.Summary {
23+
return .init(
24+
myNickname: myNickname,
25+
partnerNickname: partnerNickname,
26+
totalCount: totalCount,
27+
myCompletedCount: myCompletedCount,
28+
partnerCompltedCount: partnerCompletedCount,
29+
repeatCycle: .init(rawValue: repeatCycle) ?? .daily,
30+
startDate: startDate,
31+
endDate: endDate
32+
)
33+
}
34+
}

Projects/Domain/Stats/Interface/Sources/DTO/StatsResponseDTO.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// Created by 정지훈 on 2/25/26.
66
//
77

8-
import Foundation
8+
import CoreNetworkInterface
99

1010
/// 통계 목록 조회 응답을 디코딩하는 DTO입니다.
1111
public struct StatsResponseDTO: Decodable {
@@ -37,8 +37,15 @@ extension StatsResponseDTO {
3737
/// let dto: StatsResponseDTO = ...
3838
/// let stats = dto.toEntity(isInProgress: true)
3939
/// ```
40-
public func toEntity(isInProgress: Bool) -> Stats? {
41-
guard let firstStats = statsGoals.first else { return nil }
40+
public func toEntity(isInProgress: Bool) -> Stats {
41+
guard let firstStats = statsGoals.first
42+
else {
43+
return .init(
44+
myNickname: "",
45+
partnerNickname: "",
46+
stats: []
47+
)
48+
}
4249

4350
return Stats(
4451
myNickname: firstStats.myStats.nickname,

Projects/Domain/Stats/Interface/Sources/Endpoint/StatsEndpoint.swift

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,27 @@ import CoreNetworkInterface
1212
/// 통계 목록 조회 API 엔드포인트를 정의합니다.
1313
public enum StatsEndpoint: Endpoint {
1414
case fetchStats(selectedDate: String, status: String)
15+
case fetchStatsDetailCalendar(goalId: Int64, selectedDate: String)
16+
case fetchStatsDetailSummary(goalId: Int64)
1517
}
1618

1719
extension StatsEndpoint {
1820
public var path: String {
1921
switch self {
2022
case .fetchStats:
2123
return "/api/v1/stats"
24+
25+
case let .fetchStatsDetailCalendar(goalId, _):
26+
return "/api/v1/stats/\(goalId)/calendar"
27+
28+
case let .fetchStatsDetailSummary(goalId):
29+
return "/api/v1/stats/\(goalId)/summary"
2230
}
2331
}
2432

2533
public var method: HTTPMethod {
2634
switch self {
27-
case .fetchStats:
35+
case .fetchStats, .fetchStatsDetailCalendar, .fetchStatsDetailSummary:
2836
return .get
2937
}
3038
}
@@ -40,12 +48,20 @@ extension StatsEndpoint {
4048
.init(name: "selectedDate", value: date),
4149
.init(name: "status", value: status),
4250
]
51+
52+
case let .fetchStatsDetailCalendar(_, date):
53+
return [
54+
.init(name: "selectedDate", value: date),
55+
]
56+
57+
case .fetchStatsDetailSummary:
58+
return nil
4359
}
4460
}
4561

4662
public var body: (any Encodable)? {
4763
switch self {
48-
case .fetchStats:
64+
case .fetchStats, .fetchStatsDetailCalendar, .fetchStatsDetailSummary:
4965
return nil
5066
}
5167
}

Projects/Domain/Stats/Interface/Sources/Entity/StatsDetail.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,9 @@ public struct StatsDetail: Equatable {
1414
public let goalId: Int64
1515
public let goalName: String
1616
public var isCompleted: Bool
17+
public let yearMonth: String
1718
public let completedDate: [CompletedDate]
1819

19-
public let summary: Summary
20-
2120
/// 날짜별 목표 달성 이미지를 나타내는 모델입니다.
2221
public struct CompletedDate: Equatable {
2322
public let date: String

Projects/Domain/Stats/Interface/Sources/StatsClient.swift

Lines changed: 29 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ public struct StatsClient {
2424
/// 목표 통계를 조회합니다.
2525
public var fetchStats: (String, Bool) async throws -> Stats
2626
/// 단일 목표의 상세 통계를 조회합니다.
27-
public var fetchStatsDetail: (String) async throws -> StatsDetail
27+
public var fetchStatsDetailCalendar: (Int64, String) async throws -> StatsDetail
28+
public var fetchStatsDetailSummary: (Int64) async throws -> StatsDetail.Summary
2829

2930
/// 통계 조회 동작을 주입해 `StatsClient`를 생성합니다.
3031
///
@@ -54,10 +55,12 @@ public struct StatsClient {
5455
/// ```
5556
public init(
5657
fetchStats: @escaping (String, Bool) async throws -> Stats,
57-
fetchStatsDetail: @escaping (String) async throws -> StatsDetail,
58+
fetchStatsDetailCalendar: @escaping (Int64, String) async throws -> StatsDetail,
59+
fetchStatsDetailSummary: @escaping (Int64) async throws -> StatsDetail.Summary
5860
) {
5961
self.fetchStats = fetchStats
60-
self.fetchStatsDetail = fetchStatsDetail
62+
self.fetchStatsDetailCalendar = fetchStatsDetailCalendar
63+
self.fetchStatsDetailSummary = fetchStatsDetailSummary
6164
}
6265
}
6366

@@ -71,24 +74,13 @@ extension StatsClient: TestDependencyKey {
7174
stats: []
7275
)
7376
},
74-
fetchStatsDetail: { _ in
75-
assertionFailure("StatsClient.fetchStatsDetail이 구현되지 않았습니다. withDependencies로 mock을 주입하세요.")
76-
return .init(
77-
goalId: 1,
78-
goalName: "",
79-
isCompleted: false,
80-
completedDate: [ ],
81-
summary: .init(
82-
myNickname: "",
83-
partnerNickname: "",
84-
totalCount: 322,
85-
myCompletedCount: 82,
86-
partnerCompltedCount: 211,
87-
repeatCycle: .daily,
88-
startDate: "2026-01-07",
89-
endDate: "2027-01-07"
90-
)
91-
)
77+
fetchStatsDetailCalendar: { _, _ in
78+
assertionFailure("StatsClient.fetchStatsDetailCalendar이 구현되지 않았습니다. withDependencies로 mock을 주입하세요.")
79+
throw NetworkError.invalidResponseError
80+
},
81+
fetchStatsDetailSummary: { _ in
82+
assertionFailure("StatsClient.fetchStatsDetailSummary이 구현되지 않았습니다. withDependencies로 mock을 주입하세요.")
83+
throw NetworkError.invalidResponseError
9284
}
9385
)
9486
public static var previewValue: StatsClient = Self(
@@ -180,11 +172,12 @@ extension StatsClient: TestDependencyKey {
180172
]
181173
)
182174
},
183-
fetchStatsDetail: { _ in
175+
fetchStatsDetailCalendar: { _, _ in
184176
return .init(
185177
goalId: 1,
186178
goalName: "밥 잘 챙겨먹기",
187179
isCompleted: false,
180+
yearMonth: "2026-02",
188181
completedDate: [
189182
.init(
190183
date: "2026-02-01",
@@ -201,20 +194,22 @@ extension StatsClient: TestDependencyKey {
201194
myImageUrl: "",
202195
partnerImageUrl: ""
203196
)
204-
],
205-
summary: .init(
206-
myNickname: "현수",
207-
partnerNickname: "민정",
208-
totalCount: 322,
209-
myCompletedCount: 82,
210-
partnerCompltedCount: 211,
211-
repeatCycle: .daily,
212-
startDate: "2026-01-07",
213-
endDate: "2027-01-07"
214-
)
197+
]
198+
)
199+
},
200+
fetchStatsDetailSummary: { _ in
201+
return .init(
202+
myNickname: "현수",
203+
partnerNickname: "민정",
204+
totalCount: 322,
205+
myCompletedCount: 82,
206+
partnerCompltedCount: 211,
207+
repeatCycle: .daily,
208+
startDate: "2026-01-07",
209+
endDate: "2027-01-07"
215210
)
216211
}
217-
)
212+
)
218213
}
219214

220215
extension DependencyValues {

Projects/Domain/Stats/Sources/StatsClient+Live.swift

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,32 @@ extension StatsClient: @retroactive DependencyKey {
2323
endpoint: StatsEndpoint.fetchStats(selectedDate: date, status: status)
2424
)
2525

26-
guard let stats = response.toEntity(isInProgress: isInProgress)
27-
else { throw NetworkError.invalidResponseError }
26+
return response.toEntity(isInProgress: isInProgress)
27+
} catch {
28+
throw error
29+
}
30+
},
31+
fetchStatsDetailCalendar: { goalId, date in
32+
do {
33+
let response: StatsDetailCalendarResponseDTO = try await networkClient.request(
34+
endpoint: StatsEndpoint.fetchStatsDetailCalendar(goalId: goalId, selectedDate: date)
35+
)
2836

29-
return stats
37+
return response.toEntity()
3038
} catch {
3139
throw error
3240
}
3341
},
34-
fetchStatsDetail: { _ in
35-
// FIXME: - API 연동
36-
throw NetworkError.notFoundError
42+
fetchStatsDetailSummary: { goalId in
43+
do {
44+
let response: StatsDetailSummaryResponseDTO = try await networkClient.request(
45+
endpoint: StatsEndpoint.fetchStatsDetailSummary(goalId: goalId)
46+
)
47+
48+
return response.toEntity()
49+
} catch {
50+
throw error
51+
}
3752
}
3853
)
3954
}

Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,13 @@ extension GoalDetailReducer {
9898
)
9999

100100
case .cardSwipeLeft:
101-
state.currentUser = .mySelf
101+
state.currentUser = state.entryPoint == .home ? .mySelf : .you
102102
state.commentText = state.comment
103103
state.selectedReactionEmoji = state.currentCard?.reaction.flatMap(ReactionEmoji.init(from:))
104104
return .send(.setCreatedAt(timeFormatter.displayText(from: state.currentCard?.createdAt)))
105105

106106
case .cardSwipeRight:
107-
state.currentUser = .you
107+
state.currentUser = state.entryPoint == .home ? .you : .mySelf
108108
state.commentText = state.comment
109109
state.selectedReactionEmoji = state.currentCard?.reaction.flatMap(ReactionEmoji.init(from:))
110110
return .send(.setCreatedAt(timeFormatter.displayText(from: state.currentCard?.createdAt)))

Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,20 +61,23 @@ public struct GoalDetailView: View {
6161
public var body: some View {
6262
VStack(spacing: 0) {
6363
navigationBar
64-
cardView
65-
.padding(.horizontal, 27)
66-
.padding(.top, isSEDevice ? 47 : 103)
6764

68-
if store.isCompleted {
69-
completedBottomContent
70-
} else {
71-
bottomButton
72-
.padding(.top, 105)
73-
.frame(maxWidth: .infinity)
74-
.overlay(alignment: .topTrailing) {
75-
pokeImage
76-
.offset(x: -20, y: -20)
77-
}
65+
if store.item != nil {
66+
cardView
67+
.padding(.horizontal, 27)
68+
.padding(.top, isSEDevice ? 47 : 103)
69+
70+
if store.isCompleted {
71+
completedBottomContent
72+
} else {
73+
bottomButton
74+
.padding(.top, 105)
75+
.frame(maxWidth: .infinity)
76+
.overlay(alignment: .topTrailing) {
77+
pokeImage
78+
.offset(x: -20, y: -20)
79+
}
80+
}
7881
}
7982

8083
Spacer()

Projects/Feature/Home/Interface/Sources/Goal/EditGoalListReducer.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ public struct EditGoalListReducer {
3636

3737
public var calendarDate: TXCalendarDate
3838
public var calendarWeeks: [[TXCalendarDateItem]]
39-
public var cards: [GoalEditCardItem] = []
40-
public var hasCards: Bool { !cards.isEmpty }
39+
public var cards: [GoalEditCardItem]?
40+
public var hasCards: Bool { !(cards?.isEmpty ?? true) }
4141
public var selectedCardMenu: GoalEditCardItem?
4242
public var modal: TXModalType?
4343
public var toast: TXToastType?

0 commit comments

Comments
 (0)