Skip to content

Commit 2e002a8

Browse files
committed
feat: ranking section UI, logic 개발 완료
1 parent 56cb028 commit 2e002a8

6 files changed

Lines changed: 359 additions & 3 deletions

File tree

Projects/Feature/Challenge/Sources/Presentation/ChallengeView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ private extension ChallengeView {
3939
.tag(ChallengeFeature.State.Tab.streak)
4040

4141
ChallengeDetailView(
42-
store: store.scope(state: \.challengeDetail, action: \.challengeDetail)
42+
store: store.scope(state: \.challengeDetail, action: \.challengeDetail),
43+
imageLoader: imageLoader
4344
)
4445
.tag(ChallengeFeature.State.Tab.challenge)
4546
}

Projects/Feature/Challenge/Sources/Presentation/Children/ChallengeDetail/ChallengeDetailView.swift

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@
77

88
import SwiftUI
99
import ComposableArchitecture
10+
import CoreModyImageInterface
1011
import DesignSystem
1112

1213
public struct ChallengeDetailView: View {
1314
private let store: StoreOf<ChallengeDetailFeature>
15+
private let imageLoader: RemoteImageLoading
1416

15-
public init(store: StoreOf<ChallengeDetailFeature>) {
17+
public init(
18+
store: StoreOf<ChallengeDetailFeature>,
19+
imageLoader: RemoteImageLoading
20+
) {
1621
self.store = store
22+
self.imageLoader = imageLoader
1723
}
1824

1925
public var body: some View {
@@ -28,10 +34,26 @@ private extension ChallengeDetailView {
2834
var detailBody: some View {
2935
switch store.contentState {
3036
case .loading, .content:
31-
Text("챌린지")
37+
detailContent
3238
case .empty:
3339
ChallengeEmptyView(isStreakEmpty: false)
3440
.greedyFrame()
3541
}
3642
}
43+
44+
var detailContent: some View {
45+
ScrollView {
46+
VStack(spacing: 0) {
47+
ChallengeDetailGroupRequiredSection()
48+
49+
ChallengeDetailContributionRankingSection(
50+
rankings: store.rankings,
51+
imageLoader: imageLoader
52+
)
53+
54+
ChallengeDetailGroupOptionalSection()
55+
}
56+
}
57+
.scrollIndicators(.visible)
58+
}
3759
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
//
2+
// ChallengeDetailContributionRankingItem.swift
3+
// Challenge
4+
//
5+
// Created by 김동준 on 8/8/26.
6+
//
7+
8+
import Base
9+
import CommonDomain
10+
import CoreModyImageInterface
11+
import DesignSystem
12+
import Foundation
13+
import SwiftUI
14+
15+
struct ChallengeDetailContributionRankingItem: View {
16+
enum Layout {
17+
case podium
18+
case row
19+
}
20+
21+
private let ranking: ChallengeStepRanking?
22+
private let layout: Layout
23+
private let imageLoader: RemoteImageLoading
24+
25+
init(
26+
ranking: ChallengeStepRanking?,
27+
layout: Layout,
28+
imageLoader: RemoteImageLoading
29+
) {
30+
self.ranking = ranking
31+
self.layout = layout
32+
self.imageLoader = imageLoader
33+
}
34+
35+
var body: some View {
36+
switch layout {
37+
case .podium:
38+
podiumItem
39+
case .row:
40+
rowItem
41+
}
42+
}
43+
}
44+
45+
private extension ChallengeDetailContributionRankingItem {
46+
var podiumItem: some View {
47+
VStack(spacing: 4) {
48+
VStack(spacing: 8) {
49+
rankView(skeletonWidth: 32)
50+
avatar(size: 44)
51+
nicknameView(skeletonWidth: 48)
52+
}
53+
54+
stepCountView(skeletonWidth: 56)
55+
}
56+
}
57+
58+
var rowItem: some View {
59+
HStack(spacing: 12) {
60+
rankView(skeletonWidth: 32)
61+
avatar(size: 36)
62+
nicknameView(skeletonWidth: 48)
63+
Spacer()
64+
stepCountView(skeletonWidth: 56)
65+
}
66+
}
67+
68+
69+
var profileImageURL: URL? {
70+
guard let ranking else { return nil }
71+
72+
let trimmedURLString = ranking.profileImageUrl
73+
.trimmingCharacters(in: .whitespacesAndNewlines)
74+
guard !trimmedURLString.isEmpty else { return nil }
75+
76+
if let url = URL(string: trimmedURLString) {
77+
return url
78+
}
79+
80+
return trimmedURLString
81+
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
82+
.flatMap(URL.init(string:))
83+
}
84+
85+
var avatarStroke: (color: Color, width: CGFloat)? {
86+
guard case .podium = layout,
87+
let ranking else {
88+
return nil
89+
}
90+
91+
switch ranking.rank {
92+
case 1:
93+
return (.main, 1.4)
94+
case 2:
95+
return (.gray2, 1.2)
96+
default:
97+
return nil
98+
}
99+
}
100+
}
101+
102+
private extension ChallengeDetailContributionRankingItem {
103+
@ViewBuilder
104+
func rankView(skeletonWidth: CGFloat) -> some View {
105+
if let ranking {
106+
HStack(spacing: 0) {
107+
if ranking.rank == 1 {
108+
Image.icCrown
109+
.resizable()
110+
.frame(width: 20, height: 20)
111+
}
112+
113+
MText(
114+
"\(ranking.rank)",
115+
style: .b6,
116+
color: .gray10
117+
)
118+
}
119+
} else {
120+
SkeletonView(width: skeletonWidth, height: 22)
121+
}
122+
}
123+
124+
@ViewBuilder
125+
func avatar(size: CGFloat) -> some View {
126+
if ranking != nil {
127+
RemoteAvatarView(
128+
imageURL: profileImageURL,
129+
defaultAvatar: .smileLight,
130+
width: size,
131+
height: size,
132+
imageLoader: imageLoader
133+
)
134+
.overlay {
135+
if let avatarStroke {
136+
Circle()
137+
.strokeBorder(avatarStroke.color, lineWidth: avatarStroke.width)
138+
}
139+
}
140+
} else {
141+
SkeletonView(width: size, height: size)
142+
.clipShape(Circle())
143+
}
144+
}
145+
146+
@ViewBuilder
147+
func nicknameView(skeletonWidth: CGFloat) -> some View {
148+
if let ranking {
149+
MText(
150+
ranking.nickname,
151+
style: .b6,
152+
color: .gray10
153+
)
154+
} else {
155+
SkeletonView(width: skeletonWidth, height: 22)
156+
}
157+
}
158+
159+
@ViewBuilder
160+
func stepCountView(skeletonWidth: CGFloat) -> some View {
161+
if let ranking {
162+
HStack(alignment: .firstTextBaseline, spacing: 0) {
163+
MText(
164+
ranking.stepCount.formatted(),
165+
style: .b6,
166+
color: .gray7
167+
)
168+
169+
MText(
170+
"",
171+
style: .c2,
172+
color: .gray7
173+
)
174+
}
175+
} else {
176+
SkeletonView(width: skeletonWidth, height: 22)
177+
}
178+
}
179+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//
2+
// ChallengeDetailContributionRankingSection.swift
3+
// Challenge
4+
//
5+
// Created by 김동준 on 8/8/26.
6+
//
7+
8+
import CoreModyImageInterface
9+
import DesignSystem
10+
import SwiftUI
11+
12+
struct ChallengeDetailContributionRankingSection: View {
13+
private let rankings: [ChallengeStepRanking]?
14+
private let imageLoader: RemoteImageLoading
15+
16+
init(
17+
rankings: [ChallengeStepRanking]?,
18+
imageLoader: RemoteImageLoading
19+
) {
20+
self.rankings = rankings
21+
self.imageLoader = imageLoader
22+
}
23+
24+
var body: some View {
25+
VStack(alignment: .leading, spacing: 28) {
26+
titleText
27+
rankingContent
28+
}
29+
.padding(.horizontal, 24)
30+
.padding(.bottom, 36)
31+
.background(Color.systemWhite)
32+
}
33+
34+
private var titleText: some View {
35+
MText(
36+
"현재 기여도 순위",
37+
style: .b3,
38+
color: .gray10,
39+
alignment: .leading
40+
)
41+
}
42+
}
43+
44+
private extension ChallengeDetailContributionRankingSection {
45+
@ViewBuilder
46+
var rankingContent: some View {
47+
if let rankings {
48+
loadedRankingContent(rankings)
49+
} else {
50+
loadingRankingContent
51+
}
52+
}
53+
54+
func loadedRankingContent(_ rankings: [ChallengeStepRanking]) -> some View {
55+
VStack(spacing: 36) {
56+
HStack(alignment: .top, spacing: 0) {
57+
ForEach(Array(rankings.prefix(3)), id: \.memberId) { ranking in
58+
ChallengeDetailContributionRankingItem(
59+
ranking: ranking,
60+
layout: .podium,
61+
imageLoader: imageLoader
62+
)
63+
.greedyWidth()
64+
}
65+
}
66+
67+
if rankings.count > 3 {
68+
VStack(spacing: 20) {
69+
ForEach(Array(rankings.dropFirst(3)), id: \.memberId) { ranking in
70+
ChallengeDetailContributionRankingItem(
71+
ranking: ranking,
72+
layout: .row,
73+
imageLoader: imageLoader
74+
)
75+
}
76+
}
77+
}
78+
}
79+
}
80+
}
81+
82+
private extension ChallengeDetailContributionRankingSection {
83+
var loadingRankingContent: some View {
84+
VStack(spacing: 36) {
85+
HStack(alignment: .top, spacing: 0) {
86+
ForEach(0..<3, id: \.self) { _ in
87+
ChallengeDetailContributionRankingItem(
88+
ranking: nil,
89+
layout: .podium,
90+
imageLoader: imageLoader
91+
)
92+
.greedyWidth()
93+
}
94+
}
95+
96+
VStack(spacing: 20) {
97+
ForEach(0..<2, id: \.self) { _ in
98+
ChallengeDetailContributionRankingItem(
99+
ranking: nil,
100+
layout: .row,
101+
imageLoader: imageLoader
102+
)
103+
}
104+
}
105+
}
106+
}
107+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// ChallengeDetailGroupOptionalSection.swift
3+
// Challenge
4+
//
5+
// Created by 김동준 on 8/8/26.
6+
//
7+
8+
import DesignSystem
9+
import SwiftUI
10+
11+
struct ChallengeDetailGroupOptionalSection: View {
12+
var body: some View {
13+
MText(
14+
"그룹 선택 챌린지",
15+
style: .b3,
16+
color: .gray10,
17+
alignment: .leading
18+
)
19+
.greedyWidth(.leading)
20+
.padding(.horizontal, 24)
21+
.padding(.vertical, 32)
22+
.background(Color.gray00)
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//
2+
// ChallengeDetailGroupRequiredSection.swift
3+
// Challenge
4+
//
5+
// Created by 김동준 on 8/8/26.
6+
//
7+
8+
import DesignSystem
9+
import SwiftUI
10+
11+
struct ChallengeDetailGroupRequiredSection: View {
12+
var body: some View {
13+
MText(
14+
"그룹 필수 챌린지",
15+
style: .b3,
16+
color: .gray10,
17+
alignment: .leading
18+
)
19+
.greedyWidth(.leading)
20+
.padding(24)
21+
.background(Color.systemWhite)
22+
}
23+
}

0 commit comments

Comments
 (0)