-
Notifications
You must be signed in to change notification settings - Fork 0
[#29] Data(리포트) View, Viewmodel 구현 #40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
taebin2
wants to merge
4
commits into
develop
Choose a base branch
from
feat/#29
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
197 changes: 197 additions & 0 deletions
197
Codive/Features/Data/Presentation/Component/StatsComponent.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,197 @@ | ||
| // | ||
| // statsComponent.swift | ||
| // Codive | ||
| // | ||
| // Created by 황상환 on 12/14/25. | ||
| // | ||
|
|
||
| import SwiftUI | ||
|
|
||
| // 재사용 데이터 모델 | ||
| struct DonutSegment: Identifiable, Hashable { | ||
| let id: UUID = .init() | ||
| let value: Double | ||
| let color: Color | ||
| var payload: String? = nil // 필요하면 카테고리명, id 등 추가로 실어두기 | ||
| } | ||
|
|
||
| // 재사용 도넛 차트 | ||
| struct DonutChartView<CenterContent: View>: View { | ||
|
|
||
| let segments: [DonutSegment] | ||
| @Binding var selectedID: DonutSegment.ID? | ||
|
|
||
| var thickness: CGFloat = 45 | ||
| var gapDegrees: Double = 5 | ||
| var cornerRadius: CGFloat = 4 | ||
| var rotationDegrees: Double = -90 | ||
| var selectedScale: CGFloat = 1.08 | ||
|
|
||
| @ViewBuilder var centerContent: () -> CenterContent | ||
|
|
||
| var body: some View { | ||
| GeometryReader { geo in | ||
| let size = min(geo.size.width, geo.size.height) | ||
| let outerRadius = size / 2 | ||
| let innerRadius = max(0, outerRadius - thickness) | ||
|
|
||
| ZStack { | ||
| ForEach(computedSegments(totalSize: size, innerRadius: innerRadius, outerRadius: outerRadius)) { item in | ||
| DonutSegmentView( | ||
| color: item.segment.color, | ||
| startAngle: item.startAngle, | ||
| endAngle: item.endAngle, | ||
| innerRadius: item.innerRadius, | ||
| outerRadius: item.outerRadius, | ||
| cornerRadius: cornerRadius, | ||
| isSelected: selectedID == item.segment.id, | ||
| selectedScale: selectedScale | ||
| ) | ||
| .zIndex((selectedID == item.segment.id) ? 1 : 0) | ||
| .onTapGesture { | ||
| withAnimation(.spring()) { | ||
| selectedID = item.segment.id | ||
| } | ||
| } | ||
| } | ||
|
|
||
| centerContent() | ||
| .rotationEffect(.degrees(-rotationDegrees)) | ||
| } | ||
| .frame(width: size, height: size) | ||
| .rotationEffect(.degrees(rotationDegrees)) | ||
| } | ||
| .aspectRatio(1, contentMode: .fit) | ||
| } | ||
|
|
||
| // 각 세그먼트의 시작/끝 각도 계산 | ||
| private func computedSegments( | ||
| totalSize: CGFloat, | ||
| innerRadius: CGFloat, | ||
| outerRadius: CGFloat | ||
| ) -> [ComputedSegment] { | ||
|
|
||
| let total = segments.map(\.value).reduce(0, +) | ||
| guard total > 0, segments.isEmpty == false else { return [] } | ||
|
|
||
| let n = Double(segments.count) | ||
|
|
||
| // gap이 너무 커서 available이 음수가 되지 않도록 방어 | ||
| let safeGap = max(0, min(gapDegrees, (360.0 / n) * 0.6)) | ||
taebin2 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| let available = 360.0 - safeGap * n | ||
|
|
||
| var current = 0.0 | ||
| var result: [ComputedSegment] = [] | ||
|
|
||
| for seg in segments { | ||
| let portion = seg.value / total | ||
| let span = max(0, available * portion) | ||
|
|
||
| let start = current | ||
| let end = current + span | ||
|
|
||
| result.append( | ||
| ComputedSegment( | ||
| segment: seg, | ||
| startAngle: start, | ||
| endAngle: end, | ||
| innerRadius: innerRadius, | ||
| outerRadius: outerRadius | ||
| ) | ||
| ) | ||
|
|
||
| current = end + safeGap | ||
| } | ||
|
|
||
| return result | ||
| } | ||
|
|
||
| private struct ComputedSegment: Identifiable { | ||
| let id: DonutSegment.ID | ||
| let segment: DonutSegment | ||
| let startAngle: Double | ||
| let endAngle: Double | ||
| let innerRadius: CGFloat | ||
| let outerRadius: CGFloat | ||
|
|
||
| init(segment: DonutSegment, startAngle: Double, endAngle: Double, innerRadius: CGFloat, outerRadius: CGFloat) { | ||
| self.id = segment.id | ||
| self.segment = segment | ||
| self.startAngle = startAngle | ||
| self.endAngle = endAngle | ||
| self.innerRadius = innerRadius | ||
| self.outerRadius = outerRadius | ||
| } | ||
taebin2 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
|
|
||
| // 세그먼트 뷰 (기존 ChartSegment 역할) | ||
| private struct DonutSegmentView: View { | ||
| let color: Color | ||
| let startAngle: Double | ||
| let endAngle: Double | ||
| let innerRadius: CGFloat | ||
| let outerRadius: CGFloat | ||
| let cornerRadius: CGFloat | ||
| let isSelected: Bool | ||
| let selectedScale: CGFloat | ||
|
|
||
| var body: some View { | ||
| let strokeWidth = cornerRadius * 2 | ||
| let inset = cornerRadius | ||
|
|
||
| ZStack { | ||
| SectorShape( | ||
| startAngle: startAngle, | ||
| endAngle: endAngle, | ||
| innerRadius: innerRadius + inset, | ||
| outerRadius: outerRadius - inset | ||
| ) | ||
| .fill(color) | ||
|
|
||
| SectorShape( | ||
| startAngle: startAngle, | ||
| endAngle: endAngle, | ||
| innerRadius: innerRadius + inset, | ||
| outerRadius: outerRadius - inset | ||
| ) | ||
| .stroke(color, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .butt, lineJoin: .round)) | ||
| } | ||
| .scaleEffect(isSelected ? selectedScale : 1.0) | ||
| .animation(.spring(), value: isSelected) | ||
| } | ||
| } | ||
|
|
||
| // 섹터 Shape (rect 기반으로만 path 생성) | ||
| private struct SectorShape: Shape { | ||
| var startAngle: Double | ||
| var endAngle: Double | ||
| var innerRadius: CGFloat | ||
| var outerRadius: CGFloat | ||
|
|
||
| var animatableData: AnimatablePair<Double, Double> { | ||
| get { .init(startAngle, endAngle) } | ||
| set { startAngle = newValue.first; endAngle = newValue.second } | ||
| } | ||
|
|
||
| func path(in rect: CGRect) -> Path { | ||
| var path = Path() | ||
| let center = CGPoint(x: rect.midX, y: rect.midY) | ||
|
|
||
| let startRad = startAngle * .pi / 180 | ||
| let endRad = endAngle * .pi / 180 | ||
|
|
||
| path.addArc(center: center, radius: outerRadius, | ||
| startAngle: Angle(radians: startRad), | ||
| endAngle: Angle(radians: endRad), | ||
| clockwise: false) | ||
|
|
||
| path.addArc(center: center, radius: innerRadius, | ||
| startAngle: Angle(radians: endRad), | ||
| endAngle: Angle(radians: startRad), | ||
| clockwise: true) | ||
|
|
||
| path.closeSubpath() | ||
| return path | ||
| } | ||
| } | ||
180 changes: 180 additions & 0 deletions
180
Codive/Features/Data/Presentation/View/FavoriteByCategoryView.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| // | ||
| // FavoriteByCategoryView.swift | ||
| // Codive | ||
| // | ||
| // Created by 한태빈 on 12/19/25. | ||
| // | ||
| import SwiftUI | ||
|
|
||
| struct FavoriteByCategoryView: View { | ||
| let items: [CategoryFavoriteItem] | ||
| @Environment(\.dismiss) private var dismiss | ||
|
|
||
| var body: some View { | ||
| VStack(alignment: .leading, spacing: 0) { | ||
| CustomNavigationBar(title: "카테고리 통계") { | ||
| dismiss() | ||
| } | ||
| .background(Color.Codive.grayscale7) | ||
|
|
||
| Text("그래프를 눌러 구체적인 히스토리를 살펴보세요") | ||
| .font(.codive_body2_medium) | ||
| .foregroundStyle(Color.Codive.grayscale3) | ||
| .frame(maxWidth: .infinity, alignment: .center) | ||
| .padding(.top, 12) | ||
|
|
||
| ScrollView { | ||
| VStack(spacing: 28) { | ||
| ForEach(items) { item in | ||
| CategoryDonutSection(item: item) | ||
| } | ||
| } | ||
| .padding(.top, 22) | ||
| .padding(.bottom, 24) | ||
| } | ||
| } | ||
| .background(Color("white")) | ||
taebin2 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| .navigationBarHidden(true) | ||
| } | ||
| } | ||
|
|
||
| private struct CategoryDonutSection: View { | ||
| let item: CategoryFavoriteItem | ||
| @State private var selectedID: DonutSegment.ID? | ||
|
|
||
| private var total: Double { | ||
| item.items.map(\.value).reduce(0, +) | ||
| } | ||
|
|
||
| private var selectedSegment: DonutSegment? { | ||
| guard let selectedID else { return nil } | ||
| return item.items.first(where: { $0.id == selectedID }) | ||
| } | ||
|
|
||
| private var selectedPercent: Int { | ||
| guard let seg = selectedSegment, total > 0 else { return 0 } | ||
| return Int(round((seg.value / total) * 100)) | ||
| } | ||
|
|
||
| var body: some View { | ||
| ZStack { | ||
| DonutChartView( | ||
| segments: item.items, | ||
| selectedID: $selectedID, | ||
| thickness: 50, | ||
| gapDegrees: 5 | ||
| ) { | ||
| Text(item.categoryName) | ||
| .font(.codive_title1) | ||
| .foregroundStyle(Color.Codive.grayscale1) | ||
| } | ||
| .frame(width: 196, height: 196) | ||
|
|
||
| if let seg = selectedSegment { | ||
| BubbleLabelView( | ||
| title: seg.payload ?? "", | ||
| percent: selectedPercent | ||
| ) | ||
| .offset(x: 68, y: -62) | ||
taebin2 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| .allowsHitTesting(false) | ||
| } | ||
| } | ||
| .frame(maxWidth: .infinity) | ||
| .padding(.vertical, 6) | ||
| .onAppear { | ||
| selectedID = item.items.first?.id | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private struct BubbleLabelView: View { | ||
| let title: String | ||
| let percent: Int | ||
|
|
||
| var body: some View { | ||
| VStack(spacing: 2) { | ||
| Text(title) | ||
| .font(.codive_body2_medium) | ||
| .foregroundStyle(Color.Codive.grayscale1) | ||
|
|
||
| Text("\(percent)%") | ||
| .font(.codive_body2_medium) | ||
| .foregroundStyle(Color.Codive.grayscale1) | ||
| } | ||
| .padding(.horizontal, 12) | ||
| .padding(.vertical, 8) | ||
| .background( | ||
| SpeechBubbleShape() | ||
| .fill(Color.white) | ||
| .shadow(color: Color.black.opacity(0.10), radius: 6, x: 0, y: 3) | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private struct SpeechBubbleShape: Shape { | ||
taebin2 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| let radius: CGFloat = 10 | ||
| let tailSize: CGFloat = 6 | ||
| let tailWidth: CGFloat = 14 | ||
|
|
||
| func path(in rect: CGRect) -> Path { | ||
| var path = Path() | ||
| let bubbleHeight = rect.height - tailSize | ||
|
|
||
| path.move(to: CGPoint(x: radius, y: 0)) | ||
|
|
||
| path.addLine(to: CGPoint(x: rect.width - radius, y: 0)) | ||
| path.addArc( | ||
| center: CGPoint(x: rect.width - radius, y: radius), | ||
| radius: radius, | ||
| startAngle: .degrees(-90), | ||
| endAngle: .degrees(0), | ||
| clockwise: false | ||
| ) | ||
|
|
||
| path.addLine(to: CGPoint(x: rect.width, y: bubbleHeight - radius)) | ||
| path.addArc( | ||
| center: CGPoint(x: rect.width - radius, y: bubbleHeight - radius), | ||
| radius: radius, | ||
| startAngle: .degrees(0), | ||
| endAngle: .degrees(90), | ||
| clockwise: false | ||
| ) | ||
|
|
||
| path.addLine(to: CGPoint(x: rect.midX + (tailWidth / 2), y: bubbleHeight)) | ||
| path.addLine(to: CGPoint(x: rect.midX, y: rect.height)) | ||
| path.addLine(to: CGPoint(x: rect.midX - (tailWidth / 2), y: bubbleHeight)) | ||
|
|
||
| path.addLine(to: CGPoint(x: radius, y: bubbleHeight)) | ||
| path.addArc( | ||
| center: CGPoint(x: radius, y: bubbleHeight - radius), | ||
| radius: radius, | ||
| startAngle: .degrees(90), | ||
| endAngle: .degrees(180), | ||
| clockwise: false | ||
| ) | ||
|
|
||
| path.addLine(to: CGPoint(x: 0, y: radius)) | ||
| path.addArc( | ||
| center: CGPoint(x: radius, y: radius), | ||
| radius: radius, | ||
| startAngle: .degrees(180), | ||
| endAngle: .degrees(270), | ||
| clockwise: false | ||
| ) | ||
|
|
||
| return path | ||
| } | ||
| } | ||
|
|
||
| #Preview { | ||
| FavoriteByCategoryView(items: [ | ||
| CategoryFavoriteItem( | ||
| categoryName: "상의", | ||
| items: [ | ||
| DonutSegment(value: 5, color: Color.Codive.point1, payload: "맨투맨"), | ||
| DonutSegment(value: 3, color: Color.Codive.point2, payload: "후드티"), | ||
| DonutSegment(value: 2, color: Color.Codive.point3, payload: "셔츠") | ||
| ] | ||
| ), | ||
| ]) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.