Skip to content
Merged
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
86 changes: 33 additions & 53 deletions AIProject/iCo/Features/Base/LineChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

151 changes: 151 additions & 0 deletions AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
104 changes: 104 additions & 0 deletions AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

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
}
}