Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions Codive/Features/Data/Presentation/Component/StatsComponent.swift
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))
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
}
}
}

// 세그먼트 뷰 (기존 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 Codive/Features/Data/Presentation/View/FavoriteByCategoryView.swift
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"))
.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)
.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 {
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: "셔츠")
]
),
])
}
Loading