diff --git a/Codive/Features/Data/Domain/Entity/WardrobeStatistics.swift b/Codive/Features/Data/Domain/Entity/WardrobeStatistics.swift new file mode 100644 index 00000000..752c6809 --- /dev/null +++ b/Codive/Features/Data/Domain/Entity/WardrobeStatistics.swift @@ -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)) + } +} diff --git a/Codive/Features/Data/Presentation/Component/DataBottomSheet.swift b/Codive/Features/Data/Presentation/Component/DataBottomSheet.swift new file mode 100644 index 00000000..dff4bc23 --- /dev/null +++ b/Codive/Features/Data/Presentation/Component/DataBottomSheet.swift @@ -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, + 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() +} diff --git a/Codive/Features/Data/Presentation/Component/StatsComponent.swift b/Codive/Features/Data/Presentation/Component/StatsComponent.swift new file mode 100644 index 00000000..5d1b8ace --- /dev/null +++ b/Codive/Features/Data/Presentation/Component/StatsComponent.swift @@ -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: 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) + } +} diff --git a/Codive/Features/Data/Presentation/View/FavoriteByCategoryView.swift b/Codive/Features/Data/Presentation/View/FavoriteByCategoryView.swift new file mode 100644 index 00000000..52f64bc5 --- /dev/null +++ b/Codive/Features/Data/Presentation/View/FavoriteByCategoryView.swift @@ -0,0 +1,160 @@ +// +// 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? + @State private var isBottomSheetPresented = false + @State private var bottomSheetTitle = "" + @State private var bottomSheetItems: [DataBottomSheetClothItem] = [] + @State private var isFirstLoad = true + + 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) + .onChange(of: selectedID) { newValue in + if isFirstLoad { + isFirstLoad = false + return + } + if let seg = item.items.first(where: { $0.id == newValue }) { + bottomSheetTitle = seg.payload ?? item.categoryName + // Mock Data: 값에 따라 개수 임의 생성 (3~10개) + let count = Int(seg.value) > 0 ? Int(seg.value) + 2 : 5 + bottomSheetItems = makeMockItems(count: count) + isBottomSheetPresented = true + } + } + + 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 + // onAppear 시점에는 아직 사용자가 탭한 게 아니므로 isFirstLoad는 true 유지 + // 하지만 onChange가 호출될 수 있으므로 약간의 지연 후 false 처리하거나 + // 여기서는 selectedID 할당이 onChange를 즉시 호출하므로 위 onChange 가드문으로 방어 + } + .dataBottomSheet( + isPresented: $isBottomSheetPresented, + dataBottomSheetTitle: bottomSheetTitle, + totalCount: bottomSheetItems.count, + items: bottomSheetItems + ) + } + + private func makeMockItems(count: Int) -> [DataBottomSheetClothItem] { + (0.. Color { + if selectedIndex == index { + return Color.Codive.point1 + } + return Color.Codive.main5 + } + + private func makeMockItems(count: Int) -> [DataBottomSheetClothItem] { + // count만큼 아이템 생성 (최대 20개로 제한 등은 필요 시 추가) + let safeCount = max(0, count) + return (0.. Path { + var p = Path() + + let r = min(cornerRadius, rect.width / 2, rect.height / 2) + + p.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r)) + p.addArc( + center: CGPoint(x: rect.minX + r, y: rect.minY + r), + radius: r, + startAngle: .degrees(180), + endAngle: .degrees(270), + clockwise: false + ) + p.addLine(to: CGPoint(x: rect.maxX - r, y: rect.minY)) + p.addArc( + center: CGPoint(x: rect.maxX - r, y: rect.minY + r), + radius: r, + startAngle: .degrees(270), + endAngle: .degrees(0), + clockwise: false + ) + p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + p.closeSubpath() + + return p + } +} +#Preview { + ItemDataView(stats: [ + ItemUsageStat(itemName: "맨투맨", usageCount: 20), + ItemUsageStat(itemName: "원피스", usageCount: 15), + ItemUsageStat(itemName: "니트", usageCount: 12), + ItemUsageStat(itemName: "후드티", usageCount: 8), + ItemUsageStat(itemName: "레깅스", usageCount: 5) + ]) +} diff --git a/Codive/Features/Data/Presentation/View/MonthlyDataEmptyView.swift b/Codive/Features/Data/Presentation/View/MonthlyDataEmptyView.swift new file mode 100644 index 00000000..ed3a207e --- /dev/null +++ b/Codive/Features/Data/Presentation/View/MonthlyDataEmptyView.swift @@ -0,0 +1,83 @@ +// +// MonthlyDataEmptyView.swift +// Codive +// +// Created by 한태빈 on 12/23/25. +// + +import SwiftUI + +struct MonthlyDataEmptyView: View { + let reportTitle: String + let onBack: () -> Void + let onWriteFeed: () -> Void + + init( + reportTitle: String = "9월 옷장 리포트", + onBack: @escaping () -> Void = {}, + onWriteFeed: @escaping () -> Void = {} + ) { + self.reportTitle = reportTitle + self.onBack = onBack + self.onWriteFeed = onWriteFeed + } + + var body: some View { + VStack(spacing: 0) { + CustomNavigationBar(title: reportTitle) { + onBack() + } + .background(Color.white) + + Spacer() + + VStack(spacing: 8) { + Text("리포트를 완성하기엔 히스토리가 적어요") + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + .multilineTextAlignment(.center) + + Text("피드와 코드 기록이 쌓이면\n리포트가 완성돼요. 하나 기록해볼까요?") + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale3) + .multilineTextAlignment(.center) + .lineSpacing(2) + } + .padding(.horizontal, 48) + + buttonArea + .padding(.top, 30) + + Spacer() + } + .background(Color.white) + .navigationBarHidden(true) + } + + private var buttonArea: some View { + GeometryReader { geo in + let horizontalPadding: CGFloat = 20 + let buttonWidth = (geo.size.width - horizontalPadding * 2) / 2 + + HStack { + Spacer(minLength: horizontalPadding) + + CustomButton(text: "피드 작성하러 가기", widthType: .half) { + onWriteFeed() + } + .frame(width: buttonWidth) + + Spacer(minLength: horizontalPadding) + } + } + .frame(height: 36) + } +} + +#Preview { + MonthlyDataEmptyView( + reportTitle: "9월 옷장 리포트", + onBack: { print("back") }, + onWriteFeed: { print("write feed") } + ) +} diff --git a/Codive/Features/Data/Presentation/View/MonthlyDataView.swift b/Codive/Features/Data/Presentation/View/MonthlyDataView.swift new file mode 100644 index 00000000..cc81f648 --- /dev/null +++ b/Codive/Features/Data/Presentation/View/MonthlyDataView.swift @@ -0,0 +1,345 @@ +// +// monthlyDataView.swift +// Codive +// +// Created by 한태빈 on 12/19/25. +// + +import SwiftUI + +struct MonthlyDataView: View { + @StateObject private var viewModel = MonthlyDataViewModel() + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + CustomNavigationBar(title: viewModel.reportTitle) { + // 뒤로가기 액션 + } + .background(Color.Codive.grayscale7) + + header + .padding(.top, 4) + + FavoriteByCategory(items: viewModel.favoriteCategories) + .padding(.top, 40) + + ItemData(stats: viewModel.itemStats) + .padding(.top, 40) + + WearingData(stats: viewModel.wardrobeUsage) + .padding(.top, 40) + + Spacer(minLength: 12) + } + } + .background(Color.Codive.grayscale7) + } + + private var header: some View { + Text(viewModel.dateRangeString) + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale3) + .padding(.horizontal, 20) + } +} + +// MARK: - 공통 섹션 헤더 +private struct SectionHeader: View { + let title: String + + var body: some View { + HStack(spacing: 6) { + Text(title) + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + + Image("info") + .font(.system(size: 18)) + .foregroundStyle(Color.Codive.grayscale4) + } + .padding(.horizontal, 20) + } +} + +// MARK: - 공통 카드 컨테이너 +private struct CardContainer: View { + let height: CGFloat? + @ViewBuilder let content: Content + + init(height: CGFloat? = nil, @ViewBuilder content: () -> Content) { + self.height = height + self.content = content() + } + + var body: some View { + content + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: height) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .shadow(color: Color.black.opacity(0.04), radius: 8, x: 0, y: 4) + .padding(.horizontal, 20) + } +} + +// MARK: - FavoriteByCategory +struct FavoriteByCategory: View { + let items: [CategoryFavoriteItem] + // NavigationRouter 사용 (MonthlyDataView 상위에서 주입하거나 EnvironmentObject 사용) + @EnvironmentObject private var navigationRouter: NavigationRouter + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "카테고리별 최애 아이템") + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(items) { item in + FavoriteDonutCard( + categoryTitle: item.categoryName, + segments: item.items + ) + .frame(width: 300, height: 182) + .onTapGesture { + navigationRouter.navigate(to: AppDestination.wardrobeFavorite(items: [item])) + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 2) + } + } + } +} + +private struct FavoriteDonutCard: View { + + let categoryTitle: String + let segments: [DonutSegment] + + @State private var selectedID: DonutSegment.ID? + + private var total: Double { + segments.map(\.value).reduce(0, +) + } + + private var selectedPercentText: String { + guard let selectedID else { return "" } + guard let seg = segments.first(where: { $0.id == selectedID }) else { return "" } + guard total > 0 else { return "0%" } + let percent = Int(round((seg.value / total) * 100)) + return "\(percent)%" + } + + var body: some View { + ZStack(alignment: .topTrailing) { + HStack(spacing: 18) { + ZStack { + DonutChartView( + segments: segments, + selectedID: $selectedID, + thickness: 30, + gapDegrees: 5 + ) { + Text(categoryTitle) + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + } + .frame(width: 120, height: 120) + + PercentBubbleView(text: selectedPercentText) + .offset(x: 36, y: -42) + .opacity(selectedID == nil ? 0 : 1) + .allowsHitTesting(false) + } + + VStack(alignment: .leading, spacing: 14) { + ForEach(segments, id: \.id) { row in + HStack(spacing: 10) { + Circle() + .fill(row.color) + .frame(width: 16, height: 16) + + if let name = row.payload { + Text(name) + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale1) + } + } + } + } + + Spacer(minLength: 0) + } + .padding(16) + + Image(systemName: "chevron.right") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color.Codive.grayscale4) + .padding(.trailing, 16) + .padding(.top, 18) + } + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .shadow(color: Color.black.opacity(0.04), radius: 8, x: 0, y: 4) + .onAppear { + selectedID = segments.first?.id + } + } +} + +private struct PercentBubbleView: View { + let text: String + + var body: some View { + Text(text) + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.grayscale1) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + SpeechBubbleShape() + .fill(Color.white) + .shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3) + ) + } +} + +// MARK: - ItemData +struct ItemData: View { + let stats: [ItemUsageStat] + @State private var selectedIndex: Int? = nil // 선택된 막대 인덱스 + @EnvironmentObject private var navigationRouter: NavigationRouter + + private let maxBarHeight: CGFloat = 140 + private let minBarHeight: CGFloat = 8 + + // 가장 많이 입은 횟수를 기준으로 높이 비율 계산 + private var maxCount: Int { + stats.map(\.usageCount).max() ?? 1 + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "옷장 아이템 통계") + + CardContainer(height: 230) { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .bottom, spacing: 16) { + ForEach(Array(stats.enumerated()), id: \.offset) { idx, item in + VStack(spacing: 8) { + // 횟수 표시 + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(barColor(for: idx)) + .frame( + height: max( + minBarHeight, + CGFloat(item.usageCount) / CGFloat(maxCount) * maxBarHeight + ) + ) + + Text(item.itemName) + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.grayscale3) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 24) + .frame(height: maxBarHeight) + } + } + .onTapGesture { + navigationRouter.navigate(to: AppDestination.wardrobeItemStats(stats: stats)) + } + } + } + + private func barColor(for index: Int) -> Color { + switch index { + case 0: return Color.Codive.point1 + case 1: return Color.Codive.point2 + case 2: return Color.Codive.point3 + default: return Color.Codive.grayscale5 + } + } +} + +// MARK: - WearingData +struct WearingData: View { + let stats: WardrobeUsageStat + @State private var selectedID: DonutSegment.ID? = nil + @EnvironmentObject private var navigationRouter: NavigationRouter + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "옷장 활용도 체크") + + CardContainer(height: 198) { + HStack(spacing: 18) { + usageChart + .frame(width: 170, alignment: .leading) + + Text("보관 중인 가을 옷 \(stats.totalCount)벌 중\n\(stats.wornCount)벌을 실제로 입었어요") + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale3) + .lineSpacing(4) + + Spacer(minLength: 0) + } + } + .onTapGesture { + navigationRouter.navigate(to: AppDestination.wardrobeUsage(stats: stats)) + } + } + } + + private var usageChart: some View { + let safeTotal = max(0, stats.totalCount) + let safeWorn = max(0, min(stats.wornCount, safeTotal)) + let notWorn = max(0, safeTotal - safeWorn) + + let segments: [DonutSegment] = [ + DonutSegment(value: Double(safeWorn), color: Color.Codive.point1, payload: "입음"), + DonutSegment(value: Double(notWorn), color: Color.Codive.point4, payload: "미착용") + ] + + return VStack(spacing: 10) { + DonutChartView( + segments: segments, + selectedID: $selectedID, + thickness: 30, + gapDegrees: 0 + ) { + Text("\(stats.usagePercent)%") + .font(.system(size: 26, weight: .bold)) + .foregroundStyle(Color.Codive.point1) + } + .frame(width: 130, height: 130) + + HStack(spacing: 4) { + Text("(") + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale3) + + Text("\(safeWorn)벌") + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.point1) + + Text(" / \(safeTotal)벌)") + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale3) + } + } + .onAppear { + selectedID = nil + } + } +} +#Preview { + MonthlyDataView() + .environmentObject(NavigationRouter()) +} diff --git a/Codive/Features/Data/Presentation/View/WearingDataView.swift b/Codive/Features/Data/Presentation/View/WearingDataView.swift new file mode 100644 index 00000000..6b2bd0e0 --- /dev/null +++ b/Codive/Features/Data/Presentation/View/WearingDataView.swift @@ -0,0 +1,108 @@ +// +// WearingDataView.swift +// Codive +// +// Created by 한태빈 on 12/19/25. +// + +import SwiftUI + +struct WearingDataView: View { + let stats: WardrobeUsageStat + @Environment(\.dismiss) private var dismiss + @State private var selectedID: DonutSegment.ID? = nil + + // Bottom Sheet + @State private var isBottomSheetPresented = false + @State private var bottomSheetTitle = "" + @State private var bottomSheetItems: [DataBottomSheetClothItem] = [] + @State private var isFirstLoad = true + + var body: some View { + VStack(spacing: 0) { + CustomNavigationBar(title: "활용도 체크") { + dismiss() + } + .background(Color.Codive.grayscale7) + + Text("보관 중인 가을 옷 \(stats.totalCount)벌 중\n\(stats.wornCount)벌을 실제로 입었어요") + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale3) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + .padding(.top, 12) + .padding(.bottom, 30) + usageChart + + Spacer(minLength: 0) + } + .background(Color.white) + .navigationBarHidden(true) + .dataBottomSheet( + isPresented: $isBottomSheetPresented, + dataBottomSheetTitle: bottomSheetTitle, + totalCount: bottomSheetItems.count, + items: bottomSheetItems + ) + } + + private var usageChart: some View { + let safeTotal = max(0, stats.totalCount) + let safeWorn = max(0, min(stats.wornCount, safeTotal)) + let notWorn = max(0, safeTotal - safeWorn) + + let segments: [DonutSegment] = [ + DonutSegment(value: Double(safeWorn), color: Color.Codive.point1, payload: "입음"), + DonutSegment(value: Double(notWorn), color: Color.Codive.point4, payload: "미착용") + ] + + return DonutChartView( + segments: segments, + selectedID: $selectedID, + thickness: 45, + gapDegrees: 0 + ) { + Text("\(stats.usagePercent)%") + .font(.system(size: 26, weight: .bold)) + .foregroundStyle(Color.Codive.point1) + } + .frame(width: 196, height: 196) + .onAppear { + selectedID = nil + } + .onChange(of: selectedID) { newValue in + // 초기 로딩이나 선택 해제(nil) 시 무시 + if isFirstLoad { + isFirstLoad = false + return + } + guard let id = newValue else { return } + + if let seg = segments.first(where: { $0.id == id }) { + let payload = seg.payload ?? "" + if payload == "입음" { + bottomSheetTitle = "\(safeWorn)벌 착용" + bottomSheetItems = makeMockItems(count: safeWorn) + } else if payload == "미착용" { + bottomSheetTitle = "\(notWorn)벌 미착용" + bottomSheetItems = makeMockItems(count: notWorn) + } + isBottomSheetPresented = true + } + } + } + + private func makeMockItems(count: Int) -> [DataBottomSheetClothItem] { + (0..