Files
pulse-mobile/PulseHealth/Views/Tracker/TrackerView.swift
Cosmo e7af51af10 feat: full Pulse Mobile implementation - all modules
- Phase 0: project.yml fixes (CODE_SIGN_ENTITLEMENTS confirmed)
- Phase 1: Enhanced models (HabitModels, TaskModels, FinanceModels, SavingsModels, UserModels)
- Phase 1: Enhanced APIService with all endpoints (habits/log/stats, tasks/uncomplete, finance/analytics, savings/*)
- Phase 2: DashboardView rewrite - day progress bar, 4 stat cards, habit/task lists with Undo (3 sec)
- Phase 3: TrackerView - HabitListView (streak badge, swipe delete, archive), TaskListView (priority, overdue), StatisticsView (heatmap 84 days, line chart, bar chart via Swift Charts)
- Phase 4: FinanceView rewrite - month picker, summary card, top expenses progress bars, pie chart, line chart, transactions by day, analytics tab with bar chart + month comparison
- Phase 5: SavingsView rewrite - overview with overdue block, categories tab with type icons, operations tab with category filter + add sheet
- Phase 6: SettingsView - dark/light theme, profile edit, telegram chat id, notifications toggle + time, timezone picker, logout
- Added: AddHabitView with weekly day selector + interval days
- Added: AddTaskView with icon/color/due date picker
- Haptic feedback on all toggle actions
2026-03-25 17:14:59 +00:00

702 lines
30 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
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))
}
.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"))
}
.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 { 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
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))
}
.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"))
}
.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)
}
}