diff --git a/AIProject/iCo/Data/API/Upbit/UpbitAPIService.swift b/AIProject/iCo/Data/API/Upbit/UpbitAPIService.swift index 5fab03ac..7b400aac 100644 --- a/AIProject/iCo/Data/API/Upbit/UpbitAPIService.swift +++ b/AIProject/iCo/Data/API/Upbit/UpbitAPIService.swift @@ -8,7 +8,7 @@ import Foundation /// 업비트 API 관련 서비스를 제공합니다. -final class UpBitAPIService: UpBitApiServiceProtocol { +final class UpBitAPIService: UpBitAPIServiceProtocol { private let network: NetworkClient init(networkClient: NetworkClient = .init()) { diff --git a/AIProject/iCo/Domain/Interface/UpBitApiServiceProtocol.swift b/AIProject/iCo/Domain/Interface/UpBitAPIServiceProtocol.swift similarity index 58% rename from AIProject/iCo/Domain/Interface/UpBitApiServiceProtocol.swift rename to AIProject/iCo/Domain/Interface/UpBitAPIServiceProtocol.swift index 66766fdb..c881ac94 100644 --- a/AIProject/iCo/Domain/Interface/UpBitApiServiceProtocol.swift +++ b/AIProject/iCo/Domain/Interface/UpBitAPIServiceProtocol.swift @@ -7,6 +7,7 @@ import Foundation -protocol UpBitApiServiceProtocol { +protocol UpBitAPIServiceProtocol { func fetchQuotes(id: String) async throws -> [TickerDTO] + func fetchCandles(id: String, count: Int, to: Date?) async throws -> [MinuteCandleDTO] } diff --git a/AIProject/iCo/Domain/Model/Common/Prompt.swift b/AIProject/iCo/Domain/Model/Common/Prompt.swift index 11382535..33fc9a7b 100644 --- a/AIProject/iCo/Domain/Model/Common/Prompt.swift +++ b/AIProject/iCo/Domain/Model/Common/Prompt.swift @@ -29,7 +29,7 @@ enum Prompt { struct RecommendCoinDTO: Codable { let name: String let symbol: String - let comment: String // \(preference)인 투자자에게 추천하는 이유와 최근 동향을 100자 정도의 대화형으로 작성 + let comment: String // \(preference)인 투자자에게 추천하는 이유와 최근 동향을 200자 정도의 대화형으로 작성 } """ case .generateOverView(let coinKName): @@ -97,7 +97,7 @@ enum Prompt { let summary: String } - 커뮤니티 분위기(호재, 악재, 중립)와 그렇게 평가한 이유를 한글로 200자로 요약해 위 JSON으로 제공 (답변은 한글, 마크다운 금지, 출처 제외) + 커뮤니티 분위기(호재, 악재, 중립)와 그렇게 평가한 이유를 한글로 200자 이상으로 요약해 위 JSON으로 제공 (답변은 한글, 마크다운 금지, 출처 제외) """ case .generateBookmarkBriefing(let importance, let bookmarks): """ diff --git a/AIProject/iCo/Domain/Model/Dashboard/RecommendCoin.swift b/AIProject/iCo/Domain/Model/Dashboard/RecommendCoin.swift index b5efec53..47cab158 100644 --- a/AIProject/iCo/Domain/Model/Dashboard/RecommendCoin.swift +++ b/AIProject/iCo/Domain/Model/Dashboard/RecommendCoin.swift @@ -41,4 +41,5 @@ struct RecommendCoin: Identifiable, Hashable { let tradePrice: Double let changeRate: Double let changeType: TickerChangeType + let candles: [Double] } diff --git a/AIProject/iCo/Features/Dashboard/CardConst.swift b/AIProject/iCo/Features/Dashboard/CardConst.swift index c81f6d46..4e1a312e 100644 --- a/AIProject/iCo/Features/Dashboard/CardConst.swift +++ b/AIProject/iCo/Features/Dashboard/CardConst.swift @@ -11,7 +11,7 @@ enum CardConst { static let headerHeight: CGFloat = 140 static let headerContentSpacing: CGFloat = 30 - static let cardHeight: CGFloat = 326 + static let cardHeight: CGFloat = 250 static let cardHeightMultiplier: CGFloat = 0.9 static let cardInnerPadding: CGFloat = 16 diff --git a/AIProject/iCo/Features/Dashboard/View/CoinCarouselView.swift b/AIProject/iCo/Features/Dashboard/View/CoinCarouselView.swift index 854dd362..a532d4d3 100644 --- a/AIProject/iCo/Features/Dashboard/View/CoinCarouselView.swift +++ b/AIProject/iCo/Features/Dashboard/View/CoinCarouselView.swift @@ -42,9 +42,12 @@ struct CoinCarouselView: View { var tempCoinArray: [RecommendCoin] { wrappedCoins.flatMap { $0.map { $0 }} } - @State private var showNewBadge = false - + @State private var showDetailCoin: RecommendCoin? + @State private var measuredHeight: CGFloat = 0 + private var detent: PresentationDetent { .height(measuredHeight) } + @State private var isSheetPresented = false + var body: some View { /// 화면의 가로 크기에 따라 카드 갯수를 관리하는 computed property var numberOfColumn: Int { hSizeClass == .regular ? 2 : 1 } @@ -59,7 +62,9 @@ struct CoinCarouselView: View { width: nil, height: CardConst.cardHeight ) - .onTapGesture { selectedCoin = coin } + .onTapGesture { + selectedCoin = coin + } .scrollTransition(axis: .horizontal) { content, phase in // 활성화된 코인은 크게 보이게 하기 content.scaleEffect( y: phase.isIdentity ? 1 : CardConst.cardHeightMultiplier, @@ -116,31 +121,6 @@ struct CoinCarouselView: View { else { return } handleManualScrolling(cardID: newValue) } - .sheet(item: $selectedCoin) { coin in - VStack(spacing: 0) { - ZStack(alignment: .center) { - HeaderView( - heading: coin.name, - topPadding: 20, - coinSymbol: coin.id, - showBackButton: false, - showNewBadge: showNewBadge - ) - .toolbar(.hidden, for: .navigationBar) - - RoundedButton(imageName: "xmark") { - selectedCoin = nil - } - .frame(maxWidth: .infinity, alignment: .trailing) - .padding() - } - - CoinDetailView(coin: Coin(id: "KRW-" + coin.id, koreanName: coin.name, imageURL: coin.imageURL)) { isNew in - showNewBadge = isNew - } - } - .background(.background) - } .onAppear { // 무한 스크롤링 효과를 구현하기 위해 추천 코인 배열의 앞 뒤에 안전 코인을 붙이기 wrappedCoins = [recommendedCoins, recommendedCoins, recommendedCoins] @@ -161,6 +141,80 @@ struct CoinCarouselView: View { viewModel.stopTimer() wrappedCoins.removeAll() } + .sheet(item: $selectedCoin) { coin in + VStack(spacing: .spacing) { + CoinInfoView(recommendCoin: coin) { + selectedCoin = nil + } + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Image(systemName: "sparkles") + .font(.ico14B) + .foregroundStyle(.iCoAccent) + + Text("아이코가 추천하는 이유") + .font(.ico16B) + .foregroundStyle(.iCoLabel) + } + + Text(String.aiGeneratedContentNotice) + .font(.ico11) + .foregroundStyle(.iCoNeutral) + .lineSpacing(5) + + Text(coin.comment.byCharWrapping) + .font(.ico14) + .lineSpacing(6) + .foregroundStyle(.iCoLabel) + } + .fixedSize(horizontal: false, vertical: true) + + RoundedRectangleFillButton(title: "더 자세히 보러가기", imageName: "info.circle", isHighlighted: .constant(true)) { + selectedCoin = nil + showDetailCoin = coin + } + } + .padding(20) + .background(.background) + .background( + GeometryReader { geo in + Color.clear + .onAppear { + measuredHeight = geo.size.height + } + } + ) + .presentationDetents([detent]) + } + .onChange(of: selectedCoin) { _, newValue in + updateTimerState() + } + .sheet(item: $showDetailCoin) { detail in + ZStack(alignment: .center) { + HeaderView( + heading: detail.name, + topPadding: 20, + coinSymbol: detail.id, + showBackButton: false, + showNewBadge: showNewBadge + ) + .toolbar(.hidden, for: .navigationBar) + + RoundedButton(imageName: "xmark") { + showDetailCoin = nil + } + .frame(maxWidth: .infinity, alignment: .trailing) + .padding() + } + + CoinDetailView(coin: Coin(id: "KRW-" + detail.id, koreanName: detail.name, imageURL: detail.imageURL)) { isNew in + showNewBadge = isNew + } + } + .onChange(of: showDetailCoin) { _, newValue in + updateTimerState() + } } } @@ -230,6 +284,24 @@ extension CoinCarouselView { viewModel.startTimer() } } + + /// sheet가 보여지고 있는 경우에는 스크롤 타이머가 멈출 수 있도록 합니다. + private func updateTimerState() { + let isPresented = selectedCoin != nil || showDetailCoin != nil + if isPresented { + if !isSheetPresented { + isSheetPresented = true + viewModel.stopTimer() + print("‼️stop") + } + } else { + if isSheetPresented { + isSheetPresented = false + viewModel.startTimer() + print("‼️start") + } + } + } } #Preview { diff --git a/AIProject/iCo/Features/Dashboard/View/CoinInfoView.swift b/AIProject/iCo/Features/Dashboard/View/CoinInfoView.swift new file mode 100644 index 00000000..3f157c37 --- /dev/null +++ b/AIProject/iCo/Features/Dashboard/View/CoinInfoView.swift @@ -0,0 +1,103 @@ +// +// CoinInfoView.swift +// iCo +// +// Created by 지현 on 10/27/25. +// + +import SwiftUI + +struct CoinInfoView: View { + @EnvironmentObject var themeManager: ThemeManager + + let recommendCoin: RecommendCoin + var onCloseButtonTap: (() -> Void)? = nil + + private func dynamicStatusColor(for type: RecommendCoin.TickerChangeType) -> Color { + switch type { + case .rise: + return themeManager.selectedTheme.positiveColor + case .even: + return themeManager.selectedTheme.neutral + case .fall: + return themeManager.selectedTheme.negativeColor + } + } + + var body: some View { + VStack(spacing: 16) { + HStack(alignment: .center, spacing: .spacing) { + CoinView(symbol: recommendCoin.id, size: 50) + + VStack(alignment: .leading, spacing: 4) { + Text(recommendCoin.name) + .font(.ico17B) + .bold() + .foregroundStyle(.iCoLabel) + + Text(recommendCoin.id) + .font(.ico12Sb) + .fontWeight(.semibold) + .foregroundStyle(.iCoLabelSecondary) + } + + Spacer() + + if let onCloseButtonTap { + RoundedButton(imageName: "xmark") { + onCloseButtonTap() + } + } + } + + HStack { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 4) { + Text("현재가") + .font(.ico14) + .foregroundStyle(.iCoLabel) + + Text(recommendCoin.tradePrice.formatKRW) + .font(.ico14B) + .bold() + .foregroundStyle(dynamicStatusColor(for: recommendCoin.changeType)) + } + + HStack(spacing: 4) { + Text("전일대비") + .font(.ico14) + .foregroundStyle(.iCoLabel) + + Group { + Text("\(recommendCoin.changeType.code)\(recommendCoin.changeRate.formatRate)") + } + .font(.ico14B) + .bold() + .foregroundStyle(dynamicStatusColor(for: recommendCoin.changeType)) + } + } + + Spacer() + + LineChartView(values: recommendCoin.candles, lineColor: dynamicStatusColor(for: recommendCoin.changeType)) + .frame(width: 130, height: 40) + } + } + } +} + +#Preview { + CoinInfoView( + recommendCoin: RecommendCoin( + imageURL: nil, + comment: "펏지펭귄은 활발한 커뮤니티와 밈 기반의 인기 덕분에 최근 주목받고 있어요. 소액으로 재미있게 투자하기 좋고, 성장 가능성도 보여 기대돼요. 소액으로 재미있게 투자하기 좋고, 성장 가능성도 보여 기대돼요.", + coinID: "BTC", + name: "월드리버티파이낸셜유에스디", + tradePrice: 1600, + changeRate: 4.27, + changeType: .rise, + candles: [1.0, 3.0, 2.0, 6.0, 5.0, 3.0, 7.0, 9.0, 6.0, 10.0] + ) + ) + .environmentObject(ThemeManager()) +} diff --git a/AIProject/iCo/Features/Dashboard/View/RecommendCardView.swift b/AIProject/iCo/Features/Dashboard/View/RecommendCardView.swift index 9c3d5a12..559c0b83 100644 --- a/AIProject/iCo/Features/Dashboard/View/RecommendCardView.swift +++ b/AIProject/iCo/Features/Dashboard/View/RecommendCardView.swift @@ -8,77 +8,17 @@ import SwiftUI struct RecommendCardView: View { - @EnvironmentObject var themeManager: ThemeManager - let recommendCoin: RecommendCoin - private func dynamicStatusColor(for type: RecommendCoin.TickerChangeType) -> Color { - switch type { - case .rise: - return themeManager.selectedTheme.positiveColor - case .even: - return themeManager.selectedTheme.neutral - case .fall: - return themeManager.selectedTheme.negativeColor - } - } - var body: some View { - VStack(spacing: 0) { - VStack(alignment: .leading) { - CoinView(symbol: recommendCoin.id, size: 50) - - HStack(spacing: 10) { - Text(recommendCoin.name) - .font(.ico17) - .bold() - .foregroundStyle(.iCoLabel) - - Text(recommendCoin.id) - .font(.ico12) - .fontWeight(.semibold) - .foregroundStyle(.iCoLabelSecondary) - } - .padding(.top, 4) - - HStack(spacing: 4) { - Text("현재가") - .font(.ico14) - .foregroundStyle(.iCoLabel) - - Text(recommendCoin.tradePrice.formatKRW) - .font(.ico14) - .bold() - .foregroundStyle(dynamicStatusColor(for: recommendCoin.changeType)) - } - .padding(.top, 1) - - HStack(spacing: 4) { - Text("전일대비") - .font(.ico14) - .foregroundStyle(.iCoLabel) - - HStack(spacing: 0) { - Group { - Text("\(recommendCoin.changeType.code)\(recommendCoin.changeRate.formatRate)") - } - .font(.ico14) - .bold() - .foregroundStyle(dynamicStatusColor(for: recommendCoin.changeType)) - } - } - .padding(.top, 0.2) - } - .frame(maxWidth: .infinity, alignment: .leading) - - VStack { - Text(recommendCoin.comment.byCharWrapping) - .font(.ico14) - .lineSpacing(6) - .foregroundStyle(.iCoLabel) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.top, 32) + VStack(alignment: .leading, spacing: 0) { + CoinInfoView(recommendCoin: recommendCoin) + + Text(recommendCoin.comment.byCharWrapping) + .font(.ico14) + .lineSpacing(6) + .foregroundStyle(.iCoLabel) + .padding(.top, 30) } .padding(24) .background( @@ -99,15 +39,15 @@ struct RecommendCardView: View { RecommendCardView( recommendCoin: RecommendCoin( imageURL: nil, - comment: "좋다!", - coinID: "KRW-BTC", - name: "비트코인", + comment: "펏지펭귄은 활발한 커뮤니티와 밈 기반의 인기 덕분에 최근 주목받고 있어요. 소액으로 재미있게 투자하기 좋고, 성장 가능성도 보여 기대돼요. 소액으로 재미있게 투자하기 좋고, 성장 가능성도 보여 기대돼요.", + coinID: "BTC", + name: "월드리버티파이낸셜유에스디", tradePrice: 1600, changeRate: 4.27, - changeType: .rise + changeType: .rise, + candles: [1.0, 3.0, 2.0, 6.0, 5.0, 3.0, 7.0, 9.0, 6.0, 10.0] ) ) - .environmentObject(ThemeManager()) } diff --git a/AIProject/iCo/Features/Dashboard/ViewModel/RecommendCoinViewModel.swift b/AIProject/iCo/Features/Dashboard/ViewModel/RecommendCoinViewModel.swift index 49ad5315..279134e9 100644 --- a/AIProject/iCo/Features/Dashboard/ViewModel/RecommendCoinViewModel.swift +++ b/AIProject/iCo/Features/Dashboard/ViewModel/RecommendCoinViewModel.swift @@ -16,7 +16,7 @@ final class RecommendCoinViewModel: ObservableObject { @Published var fetchTimestamp: Date? private var llmService: LLMRecommendCoinFetching - private var upbitService: UpBitApiServiceProtocol + private var upbitService: UpBitAPIServiceProtocol var task: Task? let numberOfCoins: Int = 5 @@ -36,7 +36,7 @@ final class RecommendCoinViewModel: ObservableObject { init( llmService: LLMRecommendCoinFetching = LLMAPIService(), - upbitService: UpBitApiServiceProtocol = UpBitAPIService() + upbitService: UpBitAPIServiceProtocol = UpBitAPIService() ) { self.llmService = llmService self.upbitService = upbitService @@ -108,6 +108,8 @@ final class RecommendCoinViewModel: ObservableObject { guard let data = try await self.upbitService.fetchQuotes(id: "KRW-\(dto.symbol)").first else { return nil } + + let candleData = try await self.upbitService.fetchCandles(id: "KRW-\(dto.symbol)", count: 10, to: nil) return RecommendCoin( imageURL: nil, @@ -116,7 +118,8 @@ final class RecommendCoinViewModel: ObservableObject { name: dto.name, tradePrice: data.tradePrice, changeRate: data.changeRate * 100, - changeType: RecommendCoin.TickerChangeType(rawValue: data.change) + changeType: RecommendCoin.TickerChangeType(rawValue: data.change), + candles: candleData.map { $0.tradePrice }.reversed() ) } catch { return nil