Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
30 changes: 30 additions & 0 deletions Codive/Features/Data/Domain/Entity/WardrobeStatistics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// WardrobeStatistics.swift
// Codive
//
// Created by Codive on 12/23/25.
//

import SwiftUI

struct CategoryFavoriteItem: Identifiable, Hashable {
let id = UUID()
let categoryName: String
let items: [DonutSegment]
}

struct ItemUsageStat: Identifiable, Hashable {
let id = UUID()
let itemName: String
let usageCount: Int
}

struct WardrobeUsageStat: Hashable {
let totalCount: Int
let wornCount: Int

var usagePercent: Int {
guard totalCount > 0 else { return 0 }
return Int(round((Double(wornCount) / Double(totalCount)) * 100))
}
}
145 changes: 145 additions & 0 deletions Codive/Features/Data/Presentation/Component/DataBottomSheet.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//
// DataBottomSheet.swift
// Codive
//
// Created by 한태빈 on 2025/12/23.
//

import SwiftUI

// 바텀시트에서 사용할 아이템 모델 (나중에 API 생기면 붙일 예정)
struct DataBottomSheetClothItem: Identifiable, Hashable {
let id: UUID = .init()
let imageName: String
let brand: String
let title: String
}

struct DataBottomSheet: View {

// MARK: - Inputs
let dataBottomSheetTitle: String
let totalCount: Int
let items: [DataBottomSheetClothItem]

var onTapItem: (DataBottomSheetClothItem) -> Void = { _ in }

// MARK: - Layout constants
private let cornerRadius: CGFloat = 24
private let handleSize = CGSize(width: 52, height: 4)
private let gridHorizontalPadding: CGFloat = 0
private let headerHorizontalPadding: CGFloat = 20
private let headerTopPadding: CGFloat = 16
private let headerBottomPadding: CGFloat = 14

private let columnCount: Int = 3
private let gridSpacing: CGFloat = 0

private var columns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: gridSpacing), count: columnCount)
}

var body: some View {
VStack(spacing: 0) {
handle

header

Divider()

grid
}
.background(Color.white)
.clipShape(RoundedCorner(radius: cornerRadius, corners: [.topLeft, .topRight]))
}

private var handle: some View {
Capsule()
.fill(Color.Codive.grayscale5)
.frame(width: handleSize.width, height: handleSize.height)
.padding(.top, 10)
.padding(.bottom, 10)
}

private var header: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(dataBottomSheetTitle)
.font(.codive_title2)
.foregroundStyle(Color.Codive.grayscale1)

Text("총 \(max(0, totalCount))벌")
.font(.codive_body2_medium)
.foregroundStyle(Color.Codive.grayscale4)

Spacer(minLength: 0)
}
.padding(.horizontal, headerHorizontalPadding)
.padding(.top, headerTopPadding)
.padding(.bottom, headerBottomPadding)
}

private var grid: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: gridSpacing) {
ForEach(items) { item in
CustomClothCard(
imageName: item.imageName,
brand: item.brand,
title: item.title
) {
onTapItem(item)
}
}
}
.padding(.horizontal, gridHorizontalPadding)
.padding(.bottom, 24)
}
}
}

extension View {
func dataBottomSheet(
isPresented: Binding<Bool>,
dataBottomSheetTitle: String,
totalCount: Int,
items: [DataBottomSheetClothItem],
onTapItem: @escaping (DataBottomSheetClothItem) -> Void = { _ in }
) -> some View {
self.sheet(isPresented: isPresented) {
DataBottomSheet(
dataBottomSheetTitle: dataBottomSheetTitle,
totalCount: totalCount,
items: items,
onTapItem: onTapItem
)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
.background(Color.clear)
}
}
}

#Preview {
struct PreviewHost: View {
@State private var show = true

private let sampleItems: [DataBottomSheetClothItem] = Array(repeating: DataBottomSheetClothItem(
imageName: "samplecloth",
brand: "나이키",
title: "Cable knit cardigan navy..."
), count: 11)

var body: some View {
Color.gray.opacity(0.15)
.ignoresSafeArea()
.dataBottomSheet(
isPresented: $show,
dataBottomSheetTitle: "맨투맨",
totalCount: sampleItems.count,
items: sampleItems
)
}
}

return PreviewHost()
}
157 changes: 157 additions & 0 deletions Codive/Features/Data/Presentation/Component/StatsComponent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//
// 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 let maxGapPortion: Double = 0.6

// 각 세그먼트의 시작/끝 각도 계산
private func computedSegments(
totalSize: CGFloat,
innerRadius: CGFloat,
outerRadius: CGFloat
) -> [ComputedSegment] {

let total = segments.map(\.value).reduce(0, +)
guard total > 0, !segments.isEmpty else { return [] }

let n = Double(segments.count)

// gap이 너무 커서 available이 음수가 되지 않도록 방어
let safeGap = max(0, min(gapDegrees, (360.0 / n) * maxGapPortion))
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(
id: seg.id,
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
}
}

// 세그먼트 뷰 (기존 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)
}
}
Loading