diff --git a/AIProject/iCo/Features/Base/LineChartView.swift b/AIProject/iCo/Features/Base/LineChartView.swift index 71898151..e7136a2c 100644 --- a/AIProject/iCo/Features/Base/LineChartView.swift +++ b/AIProject/iCo/Features/Base/LineChartView.swift @@ -6,75 +6,55 @@ // import SwiftUI +import Charts /// 공통으로 사용할 수 있는 라인 차트(미니 차트) 컴포넌트 /// /// - Parameters: /// - values: 차트에 표시할 값 목록 (예: 가격 데이터) /// - lineColor: 차트 선 색상 (기본값은 `Color.iCoAccent`) -/// - showsLastDot: 마지막 지점(최근 데이터)에 점을 표시할지 여부 (`true` 시 표시) /// - lineWidth: 선의 두께 (기본값: `2`) struct LineChartView: View { - /// 차트에 표시할 값 목록 (예: 가격 데이터) let values: [Double] - - /// 차트 선 색상 (기본값: aiCoAccent) var lineColor: Color = .iCoAccent - - /// 마지막 지점 표시 여부 - var showsLastDot: Bool = true - - /// 선 두께 var lineWidth: CGFloat = 2 - - /// 내부 계산용 정규화된 값 - private var normalizedValues: [CGFloat] { - guard values.count > 1 else { return [] } - guard let min = values.min(), let max = values.max(), min != max else { - return Array(repeating: 0.5, count: values.count) - } - return values.map { CGFloat(($0 - min) / (max - min)) } - } var body: some View { - GeometryReader { geo in - if normalizedValues.isEmpty { - // 값이 없을 때 — 가운데 회색 선 표시 - Path { path in - let midY = geo.size.height / 2 - path.move(to: CGPoint(x: 0, y: midY)) - path.addLine(to: CGPoint(x: geo.size.width, y: midY)) - } - .stroke(Color.gray.opacity(0.4), lineWidth: 1) - } else { - ZStack { - // 스파크라인 - Path { path in - for (index, value) in normalizedValues.enumerated() { - let x = geo.size.width * CGFloat(index) / CGFloat(normalizedValues.count - 1) - let y = geo.size.height * (1 - value) - if index == 0 { - path.move(to: CGPoint(x: x, y: y)) - } else { - path.addLine(to: CGPoint(x: x, y: y)) - } - } - } - .stroke(lineColor, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) - - // 마지막 점 - if showsLastDot, let last = normalizedValues.last { - let x = geo.size.width - let y = geo.size.height * (1 - last) + if values.isEmpty { + Rectangle() + .fill(Color.gray.opacity(0.4)) + .frame(height: 1) + .frame(maxHeight: .infinity, alignment: .center) + } else { + let normalizedData = values.map { ($0 / (values.first ?? 1.0)) - 1.0 } - Circle() - .fill(lineColor) - .frame(width: 6, height: 6) - .position(x: x, y: y) - } + Chart { + ForEach(Array(normalizedData.enumerated()), id: \.offset) { + index, + value in + LineMark( + x: .value("Index", Double(index)), + y: .value("Change", value) + ) + .foregroundStyle(lineColor) + .interpolationMethod(.catmullRom) + + AreaMark( + x: .value("Index", Double(index)), + y: .value("Change", value) + ) + .foregroundStyle( + LinearGradient( + colors: [lineColor.opacity(0.2), .clear], + startPoint: .top, + endPoint: .bottom + ) + ) + .interpolationMethod(.catmullRom) } } + .chartXAxis(.hidden) + .chartYAxis(.hidden) } } } - diff --git a/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift b/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift new file mode 100644 index 00000000..e37ac5a9 --- /dev/null +++ b/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift @@ -0,0 +1,151 @@ +// +// TopCoinListView.swift +// iCo +// +// Created by 백현진 on 10/28/25. +// + +import SwiftUI + +struct TopCoinListView: View { + @StateObject private var viewModel = TopCoinListViewModel() + @State private var selectedTab = 0 + + var body: some View { + VStack(alignment: .leading ,spacing: 16) { + HStack { + Image(systemName: "bitcoinsign.bank.building") + .foregroundStyle(.iCoAccent) + + Text("주목할 만한 코인 TOP5") + } + .font(.ico16B) + .padding(.horizontal, 22) + .padding(.top, 20) + + SegmentedControlView( + selection: Binding( + get: { + viewModel.selectedSegment.index + }, + set: { newIndex in + viewModel.selectedSegment = TopCoinListViewModel.SegmentType.allCases[newIndex] + } + ), + tabTitles: TopCoinListViewModel.SegmentType.allCases.map { $0.rawValue }, + width: .infinity + ) + .padding(.horizontal) + + if viewModel.isLoading { + DefaultProgressView(status: .loading, message: "시세 불러오는중") + } else { + TopCoinListSection(viewModel: viewModel) + } + } + .background(.iCoBackground) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay( + RoundedRectangle(cornerRadius: 20) + .strokeBorder(.defaultGradient, lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .onAppear { + Task { + await viewModel.fetchData() + } + } + .onDisappear { + viewModel.cancelFetch() + } + } +} + +struct TopCoinListSection: View { + @ObservedObject var viewModel: TopCoinListViewModel + @State private var showNewBadge = false + @Environment(CoinStore.self) var coinStore + + var body: some View { + VStack { + ForEach(Array(viewModel.topCoins.enumerated()), id: \.element.id) { index, coin in + NavigationLink { + if let meta = coinStore.coins[coin.id] { + VStack(spacing: 0) { + HeaderView( + heading: meta.koreanName, + coinSymbol: meta.coinSymbol, + showBackButton: true, + showNewBadge: showNewBadge + ) + .toolbar(.hidden, for: .navigationBar) + + CoinDetailView(coin: meta) { isNew in + showNewBadge = isNew + } + .id(coin.id) + } + } + } label: { + HStack { + Text("\(index + 1)") + .font(.ico14B) + .foregroundColor(.iCoAccent) + .padding(.trailing, 16) + + CachedAsyncImage(resource: .symbol(coin.coinSymbol)) { + Text(String(coin.coinSymbol.prefix(1))) + .font(.ico15Sb) + .foregroundStyle(.iCoAccent) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.iCoBackgroundAccent) + .overlay( + Circle().strokeBorder(.defaultGradient, lineWidth: 0.5) + ) + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + .padding(.trailing, 8) + + VStack(alignment: .leading, spacing: 8) { + Text(viewModel.koreanName(for: coin.id)) + .font(.ico15) + if viewModel.selectedSegment == .volume { + Text(coin.formatedVolume) + .font(.ico12) + .foregroundColor(.iCoLabelSecondary) + } else { + Text(coin.formatedRate) + .font(.ico12) + .foregroundColor( + coin.change == .rise ? .iCoPositive : + (coin.change == .fall ? .iCoNegative : .iCoNeutral) + ) + } + } + + Spacer() + + if let values = viewModel.candles[coin.id] { + LineChartView( + values: values, + lineColor: coin.change == .fall ? .iCoNegative : .iCoPositive + ) + .frame(width: 100, height: 40) + } else { + ProgressView() + .frame(width: 100, height: 40) + } + } + .padding(.vertical, 16) + .padding(.horizontal, 22) + } + .buttonStyle(.plain) + } + } + } +} + +#Preview { + TopCoinListView() +} diff --git a/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift b/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift new file mode 100644 index 00000000..13fc0141 --- /dev/null +++ b/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift @@ -0,0 +1,104 @@ +// +// TopCoinListViewModel.swift +// iCo +// +// Created by 백현진 on 10/28/25. +// + +import SwiftUI + +@MainActor +final class TopCoinListViewModel: ObservableObject { + private let api = UpBitAPIService() + + @Published var tickers: [TickerValue] = [] + @Published var coins: [CoinDTO] = [] + @Published var candles: [String: [Double]] = [:] + @Published var isLoading = false + @Published var selectedSegment: SegmentType = .volume + + private var fetchTask: Task? + + enum SegmentType: String, CaseIterable, Identifiable { + case volume = "거래대금 Top5" + case rate = "상승률 Top5" + var id: String { rawValue } + + var index: Int { + Self.allCases.firstIndex(of: self) ?? 0 + } + } + + func fetchData() async { + if fetchTask != nil { return } + + fetchTask = Task { + isLoading = true + defer { + isLoading = false + fetchTask = nil + } + + do { + guard !Task.isCancelled else { return } + let coins = try await api.fetchMarkets() + self.coins = coins + + let tickers = try await api.fetchTicker(by: "KRW") + self.tickers = tickers + + let topVolumeIDs = tickers + .sorted { $0.volume > $1.volume } + .prefix(5) + .map { $0.id } + + let topRateIDs = tickers + .sorted { $0.signedRate > $1.signedRate } + .prefix(5) + .map { $0.id } + + let targetIDs = Array(Set(topVolumeIDs + topRateIDs)) + + guard !Task.isCancelled else { return } + + await withTaskGroup(of: Void.self) { group in + for id in targetIDs { + group.addTask { + do { + let candleData = try await self.api.fetchCandles(id: id, count: 10) + await MainActor.run { + self.candles[id] = candleData.map { $0.tradePrice }.reversed() + } + } catch { + print("Candle fetch failed for \(id):", error) + } + } + } + } + } catch { + print("Fetch Error:", error) + } + } + + await fetchTask?.value + } + + func cancelFetch() { + fetchTask?.cancel() + fetchTask = nil + isLoading = false + } + + var topCoins: [TickerValue] { + switch selectedSegment { + case .volume: + return Array(tickers.sorted { $0.volume > $1.volume }.prefix(5)) + case .rate: + return Array(tickers.sorted { $0.signedRate > $1.signedRate }.prefix(5)) + } + } + + func koreanName(for id: String) -> String { + coins.first(where: { $0.coinID == id })?.koreanName ?? id + } +}