diff --git a/AIProject/iCo/App/Resource/Assets.xcassets/ICoNegativeGradient.colorset/Contents.json b/AIProject/iCo/App/Resource/Assets.xcassets/ICoNegativeGradient.colorset/Contents.json new file mode 100644 index 00000000..e0a6b9a5 --- /dev/null +++ b/AIProject/iCo/App/Resource/Assets.xcassets/ICoNegativeGradient.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.945", + "green" : "0.498", + "red" : "0.357" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.784", + "green" : "0.416", + "red" : "0.298" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AIProject/iCo/App/Resource/Assets.xcassets/ICoNeutralGradient.colorset/Contents.json b/AIProject/iCo/App/Resource/Assets.xcassets/ICoNeutralGradient.colorset/Contents.json new file mode 100644 index 00000000..2e62a14d --- /dev/null +++ b/AIProject/iCo/App/Resource/Assets.xcassets/ICoNeutralGradient.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.890", + "green" : "0.408", + "red" : "0.714" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.890", + "green" : "0.408", + "red" : "0.714" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AIProject/iCo/App/Resource/Assets.xcassets/ICoPositiveGradient.colorset/Contents.json b/AIProject/iCo/App/Resource/Assets.xcassets/ICoPositiveGradient.colorset/Contents.json new file mode 100644 index 00000000..17d34ade --- /dev/null +++ b/AIProject/iCo/App/Resource/Assets.xcassets/ICoPositiveGradient.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.675", + "green" : "0.431", + "red" : "0.824" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.584", + "green" : "0.374", + "red" : "0.714" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AIProject/iCo/App/Resource/Assets.xcassets/Image.imageset/Contents.json b/AIProject/iCo/App/Resource/Assets.xcassets/Image.imageset/Contents.json new file mode 100644 index 00000000..a19a5492 --- /dev/null +++ b/AIProject/iCo/App/Resource/Assets.xcassets/Image.imageset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AIProject/iCo/Domain/Model/Dashboard/FearGreed.swift b/AIProject/iCo/Domain/Model/Dashboard/FearGreed.swift index 60eca754..9573d4f7 100644 --- a/AIProject/iCo/Domain/Model/Dashboard/FearGreed.swift +++ b/AIProject/iCo/Domain/Model/Dashboard/FearGreed.swift @@ -22,15 +22,15 @@ enum FearGreed: String { var color: Color { switch self { case .extremeFear: - return .red + return .iCoNegative case .fear: - return .orange + return .iCoNegativeGradient case .neutral: - return .yellow + return .iCoNeutralGradient case .greed: - return .green + return .iCoPositiveGradient case .extremeGreed: - return .mint + return .iCoPositive } } diff --git a/AIProject/iCo/Features/Base/RoundedButton.swift b/AIProject/iCo/Features/Base/RoundedButton.swift index ee3bbe63..08b089ff 100644 --- a/AIProject/iCo/Features/Base/RoundedButton.swift +++ b/AIProject/iCo/Features/Base/RoundedButton.swift @@ -8,30 +8,42 @@ import SwiftUI struct RoundedButton: View { + @State private var isRotated: Bool = false + var title: String? var imageName: String? var foregroundColor: Color? + var rotatingAnimation: Bool = false /// 버튼이 눌렸을 때 실행될 액션 /// /// 외부에서 이 버튼을 사용할 때 실행하고자 하는 동작을 이 클로저로 전달 /// 예: 버튼 클릭 시 네비게이션 이동, 토글, API 호출 등. - var action: () -> Void + var action: (() -> Void) var body: some View { - Button(action: action) { + Button { + action() + if rotatingAnimation { + isRotated.toggle() + } + } label: { HStack(spacing: 4) { if let title { Text(title) - .font(.ico12) + .font(.ico11) .tint(foregroundColor ?? .iCoLabel) } if let imageName { Image(systemName: imageName) .font(.ico10) - .tint(foregroundColor ?? .iCoLabel) - + .tint(foregroundColor ?? .iCoLabelSecondary) + .rotation3DEffect( + .degrees(isRotated ? 180 : 0), + axis: (x: 1, y: 0, z: 0) // Y축 기준 회전 + ) + .animation(.smooth, value: isRotated) } } .padding(.horizontal, 10) @@ -55,6 +67,7 @@ struct RoundedButton: View { .disabled(true) RoundedButton(title: "Text Only", imageName: nil, action: { }) RoundedButton(title: nil, imageName: "xmark", action: { }) + RoundedButton(title: "확장", imageName: "chevron.down", rotatingAnimation: true, action: { }) } .padding() .background(.iCoBackground) diff --git a/AIProject/iCo/Features/Dashboard/View/AIBriefingView.swift b/AIProject/iCo/Features/Dashboard/View/AIBriefingView.swift index 9e3a9b63..ce0f7e36 100644 --- a/AIProject/iCo/Features/Dashboard/View/AIBriefingView.swift +++ b/AIProject/iCo/Features/Dashboard/View/AIBriefingView.swift @@ -26,14 +26,11 @@ struct AIBriefingView: View { } var body: some View { - SubheaderView(subheading: "새로운 소식들이 있어요") + SubheaderView(subheading: "시장 한눈에 보기") .padding(.bottom, 4) VStack(alignment: .leading, spacing: 20) { - Text(String.aiGeneratedContentNotice) - .font(.ico11) - .foregroundStyle(.iCoNeutral) - .lineSpacing(5) + FearGreedView() VStack(spacing: 16) { if isPadLayout { @@ -51,8 +48,6 @@ struct AIBriefingView: View { maxHeight = value } } - - FearGreedView() } } .padding(.horizontal, 16) @@ -69,7 +64,14 @@ struct AIBriefingView: View { .font(.ico16B) .foregroundStyle($0.sentiment.color(for: themeManager.selectedTheme)) }, - content: { Text($0.summary.byCharWrapping) } + content: { + Text(String.aiGeneratedContentNotice) + .font(.ico11) + .foregroundStyle(.iCoNeutral) + .lineSpacing(5) + + Text($0.summary.byCharWrapping) + } ) .frame(height: maxHeight) } diff --git a/AIProject/iCo/Features/Dashboard/View/FearGreedView.swift b/AIProject/iCo/Features/Dashboard/View/FearGreedView.swift index 3c401469..9885d57e 100644 --- a/AIProject/iCo/Features/Dashboard/View/FearGreedView.swift +++ b/AIProject/iCo/Features/Dashboard/View/FearGreedView.swift @@ -12,28 +12,42 @@ import SwiftUI /// 왼쪽에는 지표 설명 텍스트를, 오른쪽에는 `ChartView`를 배치합니다. struct FearGreedView: View { @Environment(\.colorScheme) private var colorScheme + @Environment(\.dynamicTypeSize) var typeSize + @Environment(\.horizontalSizeClass) var hSizeClass + @StateObject private var viewModel: FearGreedViewModel = FearGreedViewModel() + @State private var showFearGreedDescription: Bool = false private static let cornerRadius: CGFloat = 20 + private var chartWidth: CGFloat { + let baseWidth: CGFloat = 110 + + switch typeSize { + case .xSmall, .small, .medium, .large: + return baseWidth + case .xLarge, .xxLarge, .xxxLarge: + return baseWidth * 0.9 + default: + return baseWidth * 0.7 + } + } var body: some View { - HStack(alignment: .center, spacing: 16) { - VStack(alignment: .leading, spacing: 8) { - Text("공포 & 탐욕 지수") - .font(.ico19B) - .foregroundStyle(.iCoLabel) + VStack { + HStack(alignment: .center, spacing: 0) { + headerSection + + Spacer() - Text("ⓘ Fear & Greed 지수는 투자 심리를 0~100 사이 수치로 나타낸 지표로, 0에 가까울수록 불안감으로 투자를 피하는 '공포', 100에 가까울수록 낙관적으로 적극 매수하는 '탐욕'을 의미합니다.".byCharWrapping) - .font(.ico11) - .foregroundStyle(.iCoLabelSecondary) - .lineSpacing(3) - .fixedSize(horizontal: false, vertical: true) + chartSection + .fixedSize(horizontal: true, vertical: false) } - Spacer() - - ChartView(viewModel: viewModel) - .frame(width: 90, height: 90) + if showFearGreedDescription { + fearGreedDescription + .opacity(!showFearGreedDescription ? 0 : 1) + .animation(.snappy(duration: 0.3), value: showFearGreedDescription) + } } .padding(.horizontal, 22) .padding(.vertical, 20) @@ -43,6 +57,76 @@ struct FearGreedView: View { RoundedRectangle(cornerRadius: Self.cornerRadius) .strokeBorder(.defaultGradient, lineWidth: 0.5) ) + .animation(.snappy(duration: 0.2), value: showFearGreedDescription) + } + + var headerSection: some View { + VStack(alignment: .leading, spacing: 4) { + if hSizeClass == .compact { + VStack(alignment: .leading, spacing: 0) { + Group { + Text("지금 시장은") + + Text(viewModel.classification) + .foregroundStyle(viewModel.fearGreed.color) + + + Text(" 상태예요") + } + .font(.ico16B) + .foregroundStyle(.iCoLabel) + } + } else { + HStack(spacing: 4) { + Group { + Text("지금 시장은") + + Text(viewModel.classification) + .foregroundStyle(viewModel.fearGreed.color) + + + Text(" 상태예요") + } + .font(.ico16B) + .foregroundStyle(.iCoLabel) + } + } + + if hSizeClass == .compact { + RoundedButton(title: "공포 탐욕 지수란?", imageName: "chevron.down", rotatingAnimation: true) { + showFearGreedDescription.toggle() + } + .fixedSize(horizontal: true, vertical: false) + .padding(.top, 8) + .offset(x: -4) + } else { + fearGreedDescription + } + } + } + + var chartSection: some View { + HStack(alignment: .bottom, spacing: .spacingSmall) { + Group { + Text("0") + .offset(y: 4) + + ChartView(viewModel: viewModel, chartWidth: chartWidth) + .frame(width: chartWidth, height: chartWidth / 2) + + Text("100") + .offset(y: 6) + } + .font(.ico10M) + .foregroundStyle(.secondary) + } + } + + var fearGreedDescription: some View { + Text("ⓘ 공포 탐욕 지수는 투자 심리를 0~100 사이 수치로 나타낸 지표로, 0에 가까울수록 불안감으로 투자를 피하는 '공포', 100에 가까울수록 낙관적으로 적극 매수하는 '탐욕'을 의미합니다.".byCharWrapping) + .font(.ico11) + .padding(.top, 4) + .foregroundStyle(.iCoNeutral) + .lineSpacing(5) + .fixedSize(horizontal: false, vertical: true) } } @@ -58,44 +142,59 @@ extension FearGreedView { fileprivate struct ChartView: View { @ObservedObject private var viewModel: FearGreedViewModel - private static let gaugeTrim: CGFloat = 0.75 - private static let lineWidth: CGFloat = 13 - private static let rotationDegrees: Double = 135 + private static let gaugeTrim: CGFloat = 0.5 + private static let lineWidth: CGFloat = 10 + private static let rotationDegrees: Double = 180 + @State private var guageValue: CGFloat = 0 - init(viewModel: FearGreedViewModel) { + let chartWidth: CGFloat + let chartHeight: CGFloat + + init(viewModel: FearGreedViewModel, chartWidth: CGFloat) { self._viewModel = ObservedObject(wrappedValue: viewModel) + self.chartWidth = chartWidth + self.chartHeight = chartWidth / 2 } var body: some View { - GeometryReader { geometry in - let size = geometry.size + ZStack { + // 배경 + Circle() + .trim(from: 0.0, to: Self.gaugeTrim) + .stroke(Color(uiColor: UIColor.systemBackground), + style: StrokeStyle(lineWidth: Self.lineWidth, lineCap: .round)) + .rotationEffect(.degrees(Self.rotationDegrees)) + .frame(height: chartHeight * 2) + .shadow(color: .black.opacity(0.15), radius: 5) - ZStack { - Circle() - .trim(from: 0.0, to: Self.gaugeTrim) - .stroke(Color.iCoBackground, style: StrokeStyle(lineWidth: Self.lineWidth, lineCap: .round)) - .rotationEffect(.degrees(Self.rotationDegrees)) - - Circle() - .trim(from: 0.0, to: Self.gaugeTrim * viewModel.indexValue / 100) - .stroke(viewModel.fearGreed.color, style: StrokeStyle(lineWidth: Self.lineWidth, lineCap: .round)) - .rotationEffect(.degrees(Self.rotationDegrees)) - - VStack(spacing: size.height * 0.15) { - Text("\(Int(viewModel.indexValue))") - .font(.dynamic(size: size.width * 0.3, weight: .bold)) - .foregroundColor(.iCoLabel) - .minimumScaleFactor(0.5) - - Text(viewModel.classification) - .font(.dynamic(size: size.width * 0.15, weight: .semibold)) - .foregroundStyle(viewModel.fearGreed.color) - .padding(.top, size.height * 0.01) - .frame(maxWidth: size.width * 0.6) - .multilineTextAlignment(.center) - .minimumScaleFactor(0.5) - } - .offset(y: size.height * 0.15) + // 실제 게이지 + Circle() + .trim(from: 0.0, to: Self.gaugeTrim * (guageValue / 100)) + .stroke( + LinearGradient( + colors: [FearGreed.extremeFear.color, FearGreed.neutral.color, FearGreed.extremeGreed.color], + startPoint: .trailing, + endPoint: .leading + ), + style: StrokeStyle(lineWidth: Self.lineWidth, lineCap: .round) + ) + .rotationEffect(.degrees(Self.rotationDegrees)) + .frame(height: chartHeight * 2) + .animation(.snappy, value: guageValue) + + Text(guageValue, format: .number) + .font(.dynamic(size: chartWidth / 5)) + .bold() + .foregroundColor(.iCoLabel) + .offset(y: -chartHeight * 0.2) + .animation(.snappy, value: guageValue) + .contentTransition(.numericText(countsDown: true)) + } + .offset(y: chartHeight / 2) + .task { + Task { + try await Task.sleep(for: .seconds(0.5)) + guageValue = viewModel.indexValue } } } @@ -104,4 +203,8 @@ extension FearGreedView { #Preview { FearGreedView() + .environmentObject(ThemeManager()) + .padding(16) + + Spacer() } diff --git a/AIProject/iCo/Features/Dashboard/ViewModel/InsightViewModel.swift b/AIProject/iCo/Features/Dashboard/ViewModel/InsightViewModel.swift index 50d925be..0d2c6c97 100644 --- a/AIProject/iCo/Features/Dashboard/ViewModel/InsightViewModel.swift +++ b/AIProject/iCo/Features/Dashboard/ViewModel/InsightViewModel.swift @@ -141,15 +141,15 @@ extension InsightViewModel { extension InsightViewModel { var sectionDataSource: [ReportSectionData] { [ - ReportSectionData( - id: "overall", - icon: "bitcoinsign.bank.building", - title: "전반적인 시장의 분위기", - state: overall, - timestamp: Date.dateAndTimeFormatter.date(from: cacheBriefTodayTimestamp), - onCancel: { [weak self] in self?.cancelOverall() }, - onRetry: { [weak self] in self?.retryOverall() } - ), +// ReportSectionData( +// id: "overall", +// icon: "bitcoinsign.bank.building", +// title: "전반적인 시장의 분위기", +// state: overall, +// timestamp: Date.dateAndTimeFormatter.date(from: cacheBriefTodayTimestamp), +// onCancel: { [weak self] in self?.cancelOverall() }, +// onRetry: { [weak self] in self?.retryOverall() } +// ), ReportSectionData( id: "community", icon: "shareplay",