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: ) { 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: { /bin/bash.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: ) { 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..() 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.. 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) } }