Files
pulse-mobile/PulseHealth/Views/Tracker/TrackerView.swift

724 lines
31 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import Charts
// MARK: - TrackerView
struct TrackerView: View {
@State private var selectedTab = 0
var body: some View {
ZStack {
Color(hex: "0a0a1a").ignoresSafeArea()
VStack(spacing: 0) {
// Header
HStack {
Text("Трекер").font(.title.bold()).foregroundColor(.white)
Spacer()
}
.padding(.horizontal)
.padding(.top, 16)
.padding(.bottom, 12)
Picker("", selection: $selectedTab) {
Text("Привычки").tag(0)
Text("Задачи").tag(1)
Text("Статистика").tag(2)
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.bottom, 12)
switch selectedTab {
case 0: HabitListView()
case 1: TaskListView()
default: StatisticsView()
}
}
}
}
}
// MARK: - HabitListView
struct HabitListView: View {
@EnvironmentObject var authManager: AuthManager
@State private var habits: [Habit] = []
@State private var isLoading = true
@State private var showAddHabit = false
@State private var showArchived = false
@State private var errorMsg: String?
@State private var showError = false
@State private var editingHabit: Habit? = nil
var activeHabits: [Habit] { habits.filter { $0.isArchived != true } }
var archivedHabits: [Habit] { habits.filter { $0.isArchived == true } }
var body: some View {
ZStack(alignment: .bottomTrailing) {
Group {
if isLoading {
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
Spacer()
} else if activeHabits.isEmpty {
VStack { EmptyState(icon: "flame", text: "Нет активных привычек"); Spacer() }
} else {
List {
ForEach(activeHabits) { habit in
HabitTrackerRow(habit: habit) { await toggleHabit(habit) }
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 3, leading: 16, bottom: 3, trailing: 16))
.onTapGesture { editingHabit = habit }
}
.onDelete { idx in
let toDelete = idx.map { activeHabits[$0] }
Task {
for h in toDelete {
try? await APIService.shared.deleteHabit(token: authManager.token, id: h.id)
}
await loadHabits(refresh: true)
}
}
if !archivedHabits.isEmpty {
Section(header: Text("Архив").foregroundColor(Color(hex: "8888aa"))) {
ForEach(archivedHabits) { habit in
HabitTrackerRow(habit: habit, isArchived: true) {}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
.refreshable { await loadHabits(refresh: true) }
Button(action: { showAddHabit = true }) {
ZStack {
Circle()
.fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 56, height: 56)
.shadow(color: Color(hex: "0D9488").opacity(0.4), radius: 8, y: 4)
Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white)
}
}
.padding(.bottom, 90)
.padding(.trailing, 20)
}
.task { await loadHabits() }
.sheet(isPresented: $showAddHabit) {
AddHabitView(isPresented: $showAddHabit) { await loadHabits(refresh: true) }
.presentationDetents([.large])
.presentationDragIndicator(.visible)
.presentationBackground(Color(hex: "0a0a1a"))
}
.sheet(item: $editingHabit) { habit in
EditHabitView(isPresented: .constant(true), habit: habit) { await loadHabits(refresh: true) }
.presentationDetents([.large])
.presentationDragIndicator(.visible)
.presentationBackground(Color(hex: "0a0a1a"))
}
.alert("Ошибка", isPresented: $showError) { Button("OK", role: .cancel) {} }
message: { Text(errorMsg ?? "") }
}
func loadHabits(refresh: Bool = false) async {
if !refresh { isLoading = true }
habits = (try? await APIService.shared.getHabits(token: authManager.token, includeArchived: true)) ?? []
isLoading = false
}
func toggleHabit(_ habit: Habit) async {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
do {
if habit.completedToday == true {
let logs = try await APIService.shared.getHabitLogs(token: authManager.token, habitId: habit.id, days: 1)
let today = todayStr()
if let log = logs.first(where: { $0.dateOnly == today }) {
try await APIService.shared.unlogHabit(token: authManager.token, habitId: habit.id, logId: log.id)
}
} else {
try await APIService.shared.logHabit(token: authManager.token, id: habit.id)
}
await loadHabits(refresh: true)
} catch APIError.serverError(let code, _) where code == 409 {
await MainActor.run {
if let idx = habits.firstIndex(where: { $0.id == habit.id }) {
habits[idx].completedToday = true
}
}
} catch { errorMsg = error.localizedDescription; showError = true }
}
func archiveHabit(_ habit: Habit) async {
var params: [String: Any] = ["is_archived": true]
if let body = try? JSONSerialization.data(withJSONObject: params) {
try? await APIService.shared.updateHabit(token: authManager.token, id: habit.id, body: body)
}
await loadHabits(refresh: true)
}
func todayStr() -> String {
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"; return df.string(from: Date())
}
}
// MARK: - HabitTrackerRow
struct HabitTrackerRow: View {
let habit: Habit
var isArchived: Bool = false
let onToggle: () async -> Void
var accentColor: Color { Color(hex: habit.accentColorHex.replacingOccurrences(of: "#", with: "")) }
var isDone: Bool { habit.completedToday == true }
var body: some View {
HStack(spacing: 14) {
ZStack {
Circle().fill(accentColor.opacity(isArchived ? 0.05 : isDone ? 0.3 : 0.15)).frame(width: 44, height: 44)
Text(habit.displayIcon).font(.title3).opacity(isArchived ? 0.4 : 1)
}
VStack(alignment: .leading, spacing: 3) {
Text(habit.name)
.font(.callout.weight(.medium))
.foregroundColor(isArchived ? Color(hex: "8888aa") : .white)
HStack(spacing: 8) {
Text(habit.frequencyLabel).font(.caption).foregroundColor(Color(hex: "8888aa"))
if let streak = habit.currentStreak, streak > 0 {
HStack(spacing: 2) {
Text("🔥").font(.caption2)
Text("\(streak) дн.").font(.caption).foregroundColor(Color(hex: "ffa502"))
}
}
}
}
Spacer()
if !isArchived {
Button(action: { Task { await onToggle() } }) {
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
.font(.title2).foregroundColor(isDone ? accentColor : Color(hex: "8888aa"))
}
} else {
Text("Архив").font(.caption).foregroundColor(Color(hex: "8888aa"))
.padding(.horizontal, 8).padding(.vertical, 4)
.background(RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(0.06)))
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(isDone && !isArchived ? accentColor.opacity(0.08) : Color.white.opacity(0.04))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(isDone && !isArchived ? accentColor.opacity(0.3) : Color.clear, lineWidth: 1))
)
}
}
// MARK: - TaskListView
struct TaskListView: View {
@EnvironmentObject var authManager: AuthManager
@State private var tasks: [PulseTask] = []
@State private var isLoading = true
@State private var filter: TaskFilter = .active
@State private var showAddTask = false
@State private var errorMsg: String?
@State private var showError = false
@State private var editingTask: PulseTask? = nil
enum TaskFilter: String, CaseIterable {
case active = "Активные"
case completed = "Выполненные"
}
var filtered: [PulseTask] {
switch filter {
case .active: return tasks.filter { !$0.completed }.sorted { ($0.priority ?? 0) > ($1.priority ?? 0) }
case .completed: return tasks.filter { $0.completed }
}
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
VStack(spacing: 0) {
Picker("", selection: $filter) {
ForEach(TaskFilter.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.bottom, 8)
if isLoading {
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
Spacer()
} else if filtered.isEmpty {
VStack { EmptyState(icon: "checkmark.circle", text: filter == .active ? "Нет активных задач" : "Нет выполненных задач"); Spacer() }
} else {
List {
ForEach(filtered) { task in
TrackerTaskRow(task: task, onToggle: { await toggleTask(task) })
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16))
.onTapGesture { editingTask = task }
}
.onDelete { idx in
let toDelete = idx.map { filtered[$0] }
Task {
for t in toDelete { try? await APIService.shared.deleteTask(token: authManager.token, id: t.id) }
await loadTasks(refresh: true)
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
.refreshable { await loadTasks(refresh: true) }
Button(action: { showAddTask = true }) {
ZStack {
Circle()
.fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 56, height: 56)
.shadow(color: Color(hex: "0D9488").opacity(0.4), radius: 8, y: 4)
Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white)
}
}
.padding(.bottom, 90)
.padding(.trailing, 20)
}
.task { await loadTasks() }
.sheet(isPresented: $showAddTask) {
AddTaskView(isPresented: $showAddTask) { await loadTasks(refresh: true) }
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationBackground(Color(hex: "0a0a1a"))
}
.sheet(item: $editingTask) { task in
EditTaskView(isPresented: .constant(true), task: task) { await loadTasks(refresh: true) }
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationBackground(Color(hex: "0a0a1a"))
}
.alert("Ошибка", isPresented: $showError) { Button("OK", role: .cancel) {} }
message: { Text(errorMsg ?? "") }
}
func loadTasks(refresh: Bool = false) async {
if !refresh { isLoading = true }
tasks = (try? await APIService.shared.getTasks(token: authManager.token)) ?? []
isLoading = false
}
func toggleTask(_ task: PulseTask) async {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
do {
if task.completed {
try await APIService.shared.uncompleteTask(token: authManager.token, id: task.id)
} else {
try await APIService.shared.completeTask(token: authManager.token, id: task.id)
}
await loadTasks(refresh: true)
} catch { errorMsg = error.localizedDescription; showError = true }
}
}
// MARK: - TrackerTaskRow
struct TrackerTaskRow: View {
let task: PulseTask
let onToggle: () async -> Void
var body: some View {
HStack(spacing: 12) {
Button(action: { Task { await onToggle() } }) {
Image(systemName: task.completed ? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundColor(task.completed ? Color(hex: "0D9488") : Color(hex: "8888aa"))
}
VStack(alignment: .leading, spacing: 3) {
Text(task.title)
.strikethrough(task.completed)
.foregroundColor(task.completed ? Color(hex: "8888aa") : .white)
.font(.callout)
HStack(spacing: 6) {
if let p = task.priority, p > 0 {
Text(task.priorityDisplayName)
.font(.caption2)
.foregroundColor(Color(hex: task.priorityColor))
.padding(.horizontal, 6).padding(.vertical, 2)
.background(RoundedRectangle(cornerRadius: 4).fill(Color(hex: task.priorityColor).opacity(0.15)))
}
if let due = task.dueDateFormatted {
Text(due).font(.caption2).foregroundColor(task.isOverdue ? Color(hex: "ff4757") : Color(hex: "8888aa"))
}
if task.isRecurring == true {
Image(systemName: "arrow.clockwise").font(.caption2).foregroundColor(Color(hex: "8888aa"))
}
}
}
Spacer()
}
.padding(12)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05)))
}
}
// MARK: - StatisticsView
struct StatisticsView: View {
@EnvironmentObject var authManager: AuthManager
@State private var habits: [Habit] = []
@State private var selectedHabitId: Int? = nil
@State private var habitStats: HabitStats?
@State private var habitLogs: [HabitLog] = []
@State private var isLoading = true
var selectedHabit: Habit? { habits.first { $0.id == selectedHabitId } }
var heatmapData: [String: Int] {
var counts: [String: Int] = [:]
for log in habitLogs { counts[log.dateOnly, default: 0] += 1 }
return counts
}
var completionPoints: [CompletionDataPoint] {
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
let cal = Calendar.current
return (0..<30).reversed().compactMap { i -> CompletionDataPoint? in
guard let date = cal.date(byAdding: .day, value: -i, to: Date()) else { return nil }
let key = df.string(from: date)
let count = heatmapData[key] ?? 0
let total = habits.filter { $0.isArchived != true }.count
let rate = total > 0 ? Double(min(count, total)) / Double(total) : 0
let label = cal.component(.day, from: date) == 1 || i == 0 ? df.string(from: date).prefix(7).description : "\(cal.component(.day, from: date))"
return CompletionDataPoint(date: date, rate: rate, label: label)
}
}
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Habit Picker
VStack(alignment: .leading, spacing: 8) {
Text("Привычка").font(.caption).foregroundColor(Color(hex: "8888aa")).padding(.horizontal)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
Button(action: { selectedHabitId = nil; Task { await loadLogs() } }) {
Text("Все")
.font(.caption.bold())
.foregroundColor(selectedHabitId == nil ? .black : .white)
.padding(.horizontal, 14).padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 20).fill(selectedHabitId == nil ? Color(hex: "0D9488") : Color.white.opacity(0.08)))
}
ForEach(habits.filter { $0.isArchived != true }) { habit in
Button(action: { selectedHabitId = habit.id; Task { await loadLogs() } }) {
HStack(spacing: 4) {
Text(habit.displayIcon).font(.caption)
Text(habit.name).font(.caption.bold())
}
.foregroundColor(selectedHabitId == habit.id ? .black : .white)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 20).fill(selectedHabitId == habit.id ? Color(hex: "0D9488") : Color.white.opacity(0.08)))
}
}
}
.padding(.horizontal)
}
}
if isLoading {
ProgressView().tint(Color(hex: "0D9488"))
} else {
// Stat Cards
if let stats = habitStats {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
StatCardSmall(icon: "flame.fill", value: "\(stats.currentStreak)", label: "Текущий streak", color: "ffa502")
StatCardSmall(icon: "trophy.fill", value: "\(stats.longestStreak)", label: "Лучший streak", color: "f59e0b")
StatCardSmall(icon: "checkmark.circle.fill", value: "\(stats.thisMonth)", label: "В этом месяце", color: "0D9488")
StatCardSmall(icon: "chart.line.uptrend.xyaxis", value: "\(stats.completionPercent)%", label: "Completion rate", color: "6366f1")
}
.padding(.horizontal)
}
// Heatmap
VStack(alignment: .leading, spacing: 8) {
Text("Активность (84 дня)")
.font(.subheadline.bold()).foregroundColor(.white).padding(.horizontal)
HeatmapView(data: heatmapData)
.padding(.horizontal)
}
// Line Chart Completion Rate 30 days
if !completionPoints.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Completion Rate (30 дней)")
.font(.subheadline.bold()).foregroundColor(.white).padding(.horizontal)
Chart(completionPoints) { point in
AreaMark(
x: .value("Дата", point.date),
y: .value("Rate", point.rate)
)
.foregroundStyle(LinearGradient(colors: [Color(hex: "0D9488").opacity(0.4), Color.clear], startPoint: .top, endPoint: .bottom))
LineMark(
x: .value("Дата", point.date),
y: .value("Rate", point.rate)
)
.foregroundStyle(Color(hex: "0D9488"))
.lineStyle(StrokeStyle(lineWidth: 2))
}
.chartYScale(domain: 0...1)
.chartYAxis {
AxisMarks(values: [0, 0.25, 0.5, 0.75, 1.0]) { val in
AxisGridLine().foregroundStyle(Color.white.opacity(0.08))
AxisValueLabel {
if let v = val.as(Double.self) {
Text("\(Int(v * 100))%").font(.caption2).foregroundStyle(Color(hex: "8888aa"))
}
}
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: 7)) { _ in
AxisGridLine().foregroundStyle(Color.white.opacity(0.05))
AxisValueLabel(format: .dateTime.day().month()).foregroundStyle(Color(hex: "8888aa"))
}
}
.frame(height: 160)
.padding(.horizontal)
}
.padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
.padding(.horizontal)
}
// Bar Chart Top Habits
if habits.filter({ $0.isArchived != true }).count > 1 {
TopHabitsChart(habits: habits.filter { $0.isArchived != true })
}
}
Spacer(minLength: 80)
}
.padding(.top, 8)
}
.task { await loadAll() }
}
func loadAll() async {
isLoading = true
habits = (try? await APIService.shared.getHabits(token: authManager.token)) ?? []
await loadLogs()
isLoading = false
}
func loadLogs() async {
if let id = selectedHabitId {
habitStats = try? await APIService.shared.getHabitStats(token: authManager.token, habitId: id)
habitLogs = (try? await APIService.shared.getHabitLogs(token: authManager.token, habitId: id, days: 90)) ?? []
} else {
habitStats = nil
// Aggregate logs from all habits
var allLogs: [HabitLog] = []
for habit in habits.filter({ $0.isArchived != true }) {
let logs = (try? await APIService.shared.getHabitLogs(token: authManager.token, habitId: habit.id, days: 90)) ?? []
allLogs.append(contentsOf: logs)
}
habitLogs = allLogs
// Build aggregate stats
let total = allLogs.count
let month = allLogs.filter { $0.dateOnly >= monthStart() }.count
habitStats = HabitStats(currentStreak: 0, longestStreak: 0, thisMonth: month, totalCompleted: total, completionRate: nil)
}
}
func monthStart() -> String {
let df = DateFormatter(); df.dateFormat = "yyyy-MM"
return df.string(from: Date()) + "-01"
}
}
// MARK: - StatCardSmall
struct StatCardSmall: View {
let icon: String
let value: String
let label: String
let color: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon).foregroundColor(Color(hex: color)).font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text(value).font(.headline.bold()).foregroundColor(.white)
Text(label).font(.caption2).foregroundColor(Color(hex: "8888aa"))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.05)))
}
}
// MARK: - HeatmapView
struct HeatmapView: View {
let data: [String: Int]
let weeks = 12
let daysPerWeek = 7
@State private var selectedDay: String?
var maxCount: Int { max(data.values.max() ?? 1, 1) }
var cells: [[String]] {
let cal = Calendar.current
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
let today = Date()
// Build 84 days back
let totalDays = weeks * daysPerWeek
var days: [String] = (0..<totalDays).reversed().compactMap { i in
cal.date(byAdding: .day, value: -i, to: today).map { df.string(from: $0) }
}
// Pad to fill weeks
var result: [[String]] = []
var col: [String] = []
for d in days {
col.append(d)
if col.count == 7 { result.append(col); col = [] }
}
if !col.isEmpty {
while col.count < 7 { col.insert("", at: 0) }
result.insert(col, at: 0)
}
return result
}
var monthLabels: [(String, Int)] {
var labels: [(String, Int)] = []
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
let mf = DateFormatter(); mf.dateFormat = "MMM"
var seenMonths = Set<String>()
for (ci, col) in cells.enumerated() {
for dayStr in col {
guard !dayStr.isEmpty, let date = df.date(from: dayStr) else { continue }
let cal = Calendar.current
if cal.component(.day, from: date) <= 7 {
let label = mf.string(from: date)
if !seenMonths.contains(label) { seenMonths.insert(label); labels.append((label, ci)) }
}
}
}
return labels
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
// Month labels
HStack(spacing: 0) {
ForEach(0..<cells.count, id: \.self) { ci in
let label = monthLabels.first { $0.1 == ci }?.0 ?? ""
Text(label)
.font(.system(size: 9))
.foregroundColor(Color(hex: "8888aa"))
.frame(maxWidth: .infinity)
}
}
// Grid
HStack(spacing: 3) {
ForEach(0..<cells.count, id: \.self) { ci in
VStack(spacing: 3) {
ForEach(0..<7, id: \.self) { ri in
let dayStr = cells[ci][ri]
let count = data[dayStr] ?? 0
let intensity = dayStr.isEmpty ? 0.0 : count == 0 ? 0.0 : min(Double(count) / Double(maxCount), 1.0)
let cellColor = dayStr.isEmpty ? Color.clear :
count == 0 ? Color(hex: "0D9488").opacity(0.07) :
Color(hex: "0D9488").opacity(0.15 + intensity * 0.85)
RoundedRectangle(cornerRadius: 2)
.fill(cellColor)
.frame(width: 12, height: 12)
.overlay(
selectedDay == dayStr && !dayStr.isEmpty ?
RoundedRectangle(cornerRadius: 2).stroke(Color.white.opacity(0.8), lineWidth: 1) : nil
)
.onTapGesture {
guard !dayStr.isEmpty else { return }
selectedDay = selectedDay == dayStr ? nil : dayStr
}
}
}
}
}
// Tooltip
if let day = selectedDay {
let count = data[day] ?? 0
HStack(spacing: 6) {
Image(systemName: "info.circle").font(.caption2).foregroundColor(Color(hex: "0D9488"))
Text("\(formatDay(day)): \(count) выполнений")
.font(.caption).foregroundColor(Color(hex: "8888aa"))
}
.padding(.top, 4)
}
// Legend
HStack(spacing: 4) {
Text("Меньше").font(.system(size: 9)).foregroundColor(Color(hex: "8888aa"))
ForEach([0.1, 0.3, 0.5, 0.7, 1.0], id: \.self) { v in
RoundedRectangle(cornerRadius: 2)
.fill(Color(hex: "0D9488").opacity(v))
.frame(width: 10, height: 10)
}
Text("Больше").font(.system(size: 9)).foregroundColor(Color(hex: "8888aa"))
}
.padding(.top, 4)
}
.padding(14)
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
}
func formatDay(_ s: String) -> String {
let parts = s.split(separator: "-")
guard parts.count == 3 else { return s }
return "\(parts[2]).\(parts[1]).\(parts[0])"
}
}
// MARK: - TopHabitsChart
struct TopHabitsChart: View {
let habits: [Habit]
var sorted: [Habit] { habits.sorted { ($0.currentStreak ?? 0) > ($1.currentStreak ?? 0) }.prefix(5).map { $0 } }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Топ привычек по streak")
.font(.subheadline.bold()).foregroundColor(.white)
Chart(sorted) { habit in
BarMark(
x: .value("Streak", habit.currentStreak ?? 0),
y: .value("Привычка", habit.name)
)
.foregroundStyle(Color(hex: "0D9488"))
.cornerRadius(4)
}
.chartXAxis {
AxisMarks { _ in
AxisGridLine().foregroundStyle(Color.white.opacity(0.08))
AxisValueLabel().foregroundStyle(Color(hex: "8888aa"))
}
}
.chartYAxis {
AxisMarks { v in
AxisValueLabel().foregroundStyle(Color(hex: "8888aa"))
}
}
.frame(height: 180)
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
.padding(.horizontal)
}
}