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
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - FinanceTransaction
|
||||||
|
|
||||||
struct FinanceTransaction: Codable, Identifiable {
|
struct FinanceTransaction: Codable, Identifiable {
|
||||||
let id: Int
|
let id: Int
|
||||||
var amount: Double
|
var amount: Double
|
||||||
@@ -9,6 +11,17 @@ struct FinanceTransaction: Codable, Identifiable {
|
|||||||
var date: String?
|
var date: String?
|
||||||
var createdAt: String?
|
var createdAt: String?
|
||||||
|
|
||||||
|
var isIncome: Bool { type == "income" }
|
||||||
|
|
||||||
|
var dateFormatted: String {
|
||||||
|
guard let d = date else { return "" }
|
||||||
|
let parts = String(d.prefix(10)).split(separator: "-")
|
||||||
|
guard parts.count == 3 else { return String(d.prefix(10)) }
|
||||||
|
return "\(parts[2]).\(parts[1]).\(parts[0])"
|
||||||
|
}
|
||||||
|
|
||||||
|
var dateOnly: String { String(date?.prefix(10) ?? "") }
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, amount, description, type, date
|
case id, amount, description, type, date
|
||||||
case categoryId = "category_id"
|
case categoryId = "category_id"
|
||||||
@@ -16,6 +29,8 @@ struct FinanceTransaction: Codable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - FinanceCategory
|
||||||
|
|
||||||
struct FinanceCategory: Codable, Identifiable {
|
struct FinanceCategory: Codable, Identifiable {
|
||||||
let id: Int
|
let id: Int
|
||||||
var name: String
|
var name: String
|
||||||
@@ -24,21 +39,66 @@ struct FinanceCategory: Codable, Identifiable {
|
|||||||
var type: String
|
var type: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - FinanceSummary
|
||||||
|
|
||||||
struct FinanceSummary: Codable {
|
struct FinanceSummary: Codable {
|
||||||
var totalIncome: Double?
|
var totalIncome: Double?
|
||||||
var totalExpense: Double?
|
var totalExpense: Double?
|
||||||
var balance: Double?
|
var balance: Double?
|
||||||
var carriedOver: Double?
|
var carriedOver: Double?
|
||||||
var month: String?
|
var month: String?
|
||||||
|
var byCategory: [CategorySpend]?
|
||||||
|
var daily: [DailySpend]?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case balance, month
|
case balance, month
|
||||||
case totalIncome = "total_income"
|
case totalIncome = "total_income"
|
||||||
case totalExpense = "total_expense"
|
case totalExpense = "total_expense"
|
||||||
case carriedOver = "carried_over"
|
case carriedOver = "carried_over"
|
||||||
|
case byCategory = "by_category"
|
||||||
|
case daily
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CategorySpend: Codable, Identifiable {
|
||||||
|
var id: Int { categoryId ?? 0 }
|
||||||
|
var categoryId: Int?
|
||||||
|
var categoryName: String?
|
||||||
|
var total: Double?
|
||||||
|
var icon: String?
|
||||||
|
var color: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case total, icon, color
|
||||||
|
case categoryId = "category_id"
|
||||||
|
case categoryName = "category_name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DailySpend: Codable, Identifiable {
|
||||||
|
var id: String { date }
|
||||||
|
let date: String
|
||||||
|
let total: Double?
|
||||||
|
let income: Double?
|
||||||
|
let expense: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - FinanceAnalytics
|
||||||
|
|
||||||
|
struct FinanceAnalytics: Codable {
|
||||||
|
var currentMonth: FinanceSummary?
|
||||||
|
var previousMonth: FinanceSummary?
|
||||||
|
var byCategory: [CategorySpend]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case currentMonth = "current_month"
|
||||||
|
case previousMonth = "previous_month"
|
||||||
|
case byCategory = "by_category"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CreateTransactionRequest
|
||||||
|
|
||||||
struct CreateTransactionRequest: Codable {
|
struct CreateTransactionRequest: Codable {
|
||||||
var amount: Double
|
var amount: Double
|
||||||
var categoryId: Int?
|
var categoryId: Int?
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum HabitFrequency: String, Codable {
|
// MARK: - Habit Frequency
|
||||||
case daily, weekly, monthly
|
|
||||||
|
enum HabitFrequency: String, Codable, CaseIterable {
|
||||||
|
case daily, weekly, monthly, interval, custom
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .daily: return "Ежедневно"
|
case .daily: return "Ежедневно"
|
||||||
case .weekly: return "Еженедельно"
|
case .weekly: return "Еженедельно"
|
||||||
case .monthly: return "Ежемесячно"
|
case .monthly: return "Ежемесячно"
|
||||||
|
case .interval: return "Через N дней"
|
||||||
|
case .custom: return "Особое"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Habit
|
||||||
|
|
||||||
struct Habit: Codable, Identifiable {
|
struct Habit: Codable, Identifiable {
|
||||||
let id: Int
|
let id: Int
|
||||||
var name: String
|
var name: String
|
||||||
@@ -25,6 +31,13 @@ struct Habit: Codable, Identifiable {
|
|||||||
var longestStreak: Int?
|
var longestStreak: Int?
|
||||||
var completedToday: Bool?
|
var completedToday: Bool?
|
||||||
var totalCompleted: Int?
|
var totalCompleted: Int?
|
||||||
|
var isArchived: Bool?
|
||||||
|
var startDate: String?
|
||||||
|
var createdAt: String?
|
||||||
|
var updatedAt: String?
|
||||||
|
|
||||||
|
var accentColorHex: String { color ?? "00d4aa" }
|
||||||
|
var displayIcon: String { icon ?? "🔥" }
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, name, description, icon, color, frequency
|
case id, name, description, icon, color, frequency
|
||||||
@@ -35,9 +48,105 @@ struct Habit: Codable, Identifiable {
|
|||||||
case longestStreak = "longest_streak"
|
case longestStreak = "longest_streak"
|
||||||
case completedToday = "completed_today"
|
case completedToday = "completed_today"
|
||||||
case totalCompleted = "total_completed"
|
case totalCompleted = "total_completed"
|
||||||
|
case isArchived = "is_archived"
|
||||||
|
case startDate = "start_date"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case updatedAt = "updated_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
var frequencyLabel: String {
|
||||||
|
switch frequency {
|
||||||
|
case .daily: return "Ежедневно"
|
||||||
|
case .weekly:
|
||||||
|
guard let days = targetDays, !days.isEmpty else { return "Еженедельно" }
|
||||||
|
let names = ["Вс","Пн","Вт","Ср","Чт","Пт","Сб"]
|
||||||
|
return days.sorted().compactMap { names[safe: $0] }.joined(separator: ", ")
|
||||||
|
case .interval:
|
||||||
|
let n = targetCount ?? 1
|
||||||
|
return "Каждые \(n) дн."
|
||||||
|
case .monthly: return "Ежемесячно"
|
||||||
|
case .custom: return "Особое"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - HabitLog
|
||||||
|
|
||||||
|
struct HabitLog: Codable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let habitId: Int?
|
||||||
|
let completedAt: String?
|
||||||
|
let note: String?
|
||||||
|
|
||||||
|
var dateOnly: String { String(completedAt?.prefix(10) ?? "") }
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case habitId = "habit_id"
|
||||||
|
case completedAt = "completed_at"
|
||||||
|
case note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HabitFreeze
|
||||||
|
|
||||||
|
struct HabitFreeze: Codable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let habitId: Int?
|
||||||
|
let startDate: String
|
||||||
|
let endDate: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case habitId = "habit_id"
|
||||||
|
case startDate = "start_date"
|
||||||
|
case endDate = "end_date"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HabitStats
|
||||||
|
|
||||||
|
struct HabitStats: Codable {
|
||||||
|
let currentStreak: Int
|
||||||
|
let longestStreak: Int
|
||||||
|
let thisMonth: Int
|
||||||
|
let totalCompleted: Int?
|
||||||
|
let completionRate: Double?
|
||||||
|
|
||||||
|
var completionPercent: Int { Int((completionRate ?? 0) * 100) }
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case currentStreak = "current_streak"
|
||||||
|
case longestStreak = "longest_streak"
|
||||||
|
case thisMonth = "this_month"
|
||||||
|
case totalCompleted = "total_completed"
|
||||||
|
case completionRate = "completion_rate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HabitsOverallStats
|
||||||
|
|
||||||
|
struct HabitsOverallStats: Codable {
|
||||||
|
let todayCompleted: Int?
|
||||||
|
let activeHabits: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case todayCompleted = "today_completed"
|
||||||
|
case activeHabits = "active_habits"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CompletionDataPoint (for charts)
|
||||||
|
|
||||||
|
struct CompletionDataPoint: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let date: Date
|
||||||
|
let rate: Double
|
||||||
|
let label: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HabitLogRequest
|
||||||
|
|
||||||
struct HabitLogRequest: Codable {
|
struct HabitLogRequest: Codable {
|
||||||
var completedAt: String?
|
var completedAt: String?
|
||||||
var note: String?
|
var note: String?
|
||||||
@@ -46,3 +155,12 @@ struct HabitLogRequest: Codable {
|
|||||||
case note
|
case note
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Safe Array Subscript
|
||||||
|
|
||||||
|
extension Array {
|
||||||
|
subscript(safe index: Int) -> Element? {
|
||||||
|
guard index >= 0, index < count else { return nil }
|
||||||
|
return self[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,28 +16,40 @@ struct SavingsCategory: Codable, Identifiable {
|
|||||||
var depositStartDate: String?
|
var depositStartDate: String?
|
||||||
var depositEndDate: String?
|
var depositEndDate: String?
|
||||||
var recurringAmount: Double?
|
var recurringAmount: Double?
|
||||||
|
var recurringDay: Int?
|
||||||
|
|
||||||
var icon: String {
|
var icon: String {
|
||||||
|
if isCredit == true { return "creditcard.fill" }
|
||||||
if isDeposit == true { return "percent" }
|
if isDeposit == true { return "percent" }
|
||||||
if isAccount == true { return "building.columns.fill" }
|
if isAccount == true { return "building.columns.fill" }
|
||||||
if isRecurring == true { return "arrow.clockwise" }
|
if isRecurring == true { return "arrow.clockwise" }
|
||||||
return "banknote.fill"
|
return "banknote.fill"
|
||||||
}
|
}
|
||||||
|
|
||||||
var color: String {
|
var colorHex: String {
|
||||||
|
if isCredit == true { return "ff4757" }
|
||||||
if isDeposit == true { return "ffa502" }
|
if isDeposit == true { return "ffa502" }
|
||||||
if isAccount == true { return "7c3aed" }
|
if isAccount == true { return "7c3aed" }
|
||||||
if isRecurring == true { return "00d4aa" }
|
if isRecurring == true { return "00d4aa" }
|
||||||
return "8888aa"
|
return "8888aa"
|
||||||
}
|
}
|
||||||
|
|
||||||
var typeLabel: String {
|
var typeLabel: String {
|
||||||
|
if isCredit == true { return "Кредит \(Int(interestRate ?? 0))%" }
|
||||||
if isDeposit == true { return "Вклад \(Int(interestRate ?? 0))%" }
|
if isDeposit == true { return "Вклад \(Int(interestRate ?? 0))%" }
|
||||||
if isAccount == true { return "Счёт" }
|
if isAccount == true { return "Счёт" }
|
||||||
if isRecurring == true { return "Накопления" }
|
if isRecurring == true { return "Регулярные" }
|
||||||
return "Копилка"
|
return "Накопление"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var typeEmoji: String {
|
||||||
|
if isCredit == true { return "💳" }
|
||||||
|
if isDeposit == true { return "🏦" }
|
||||||
|
if isAccount == true { return "🏧" }
|
||||||
|
if isRecurring == true { return "🔄" }
|
||||||
|
return "💰"
|
||||||
|
}
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, name, description
|
case id, name, description
|
||||||
case isDeposit = "is_deposit"
|
case isDeposit = "is_deposit"
|
||||||
@@ -52,6 +64,7 @@ struct SavingsCategory: Codable, Identifiable {
|
|||||||
case depositStartDate = "deposit_start_date"
|
case depositStartDate = "deposit_start_date"
|
||||||
case depositEndDate = "deposit_end_date"
|
case depositEndDate = "deposit_end_date"
|
||||||
case recurringAmount = "recurring_amount"
|
case recurringAmount = "recurring_amount"
|
||||||
|
case recurringDay = "recurring_day"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,9 +79,18 @@ struct SavingsTransaction: Codable, Identifiable {
|
|||||||
var createdAt: String?
|
var createdAt: String?
|
||||||
var categoryName: String?
|
var categoryName: String?
|
||||||
var userName: String?
|
var userName: String?
|
||||||
|
|
||||||
var isDeposit: Bool { type == "deposit" }
|
var isDeposit: Bool { type == "deposit" }
|
||||||
|
|
||||||
|
var dateFormatted: String {
|
||||||
|
guard let d = date else { return "" }
|
||||||
|
let parts = String(d.prefix(10)).split(separator: "-")
|
||||||
|
guard parts.count == 3 else { return String(d.prefix(10)) }
|
||||||
|
return "\(parts[2]).\(parts[1]).\(parts[0])"
|
||||||
|
}
|
||||||
|
|
||||||
|
var dateOnly: String { String(date?.prefix(10) ?? "") }
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, amount, type, description, date
|
case id, amount, type, description, date
|
||||||
case categoryId = "category_id"
|
case categoryId = "category_id"
|
||||||
@@ -84,11 +106,30 @@ struct SavingsStats: Codable {
|
|||||||
var totalDeposits: Double?
|
var totalDeposits: Double?
|
||||||
var totalWithdrawals: Double?
|
var totalWithdrawals: Double?
|
||||||
var categoriesCount: Int?
|
var categoriesCount: Int?
|
||||||
|
var monthlyPayments: Double?
|
||||||
|
var overdueAmount: Double?
|
||||||
|
var overdueCount: Int?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case totalBalance = "total_balance"
|
case totalBalance = "total_balance"
|
||||||
case totalDeposits = "total_deposits"
|
case totalDeposits = "total_deposits"
|
||||||
case totalWithdrawals = "total_withdrawals"
|
case totalWithdrawals = "total_withdrawals"
|
||||||
case categoriesCount = "categories_count"
|
case categoriesCount = "categories_count"
|
||||||
|
case monthlyPayments = "monthly_payments"
|
||||||
|
case overdueAmount = "overdue_amount"
|
||||||
|
case overdueCount = "overdue_count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateSavingsTransactionRequest: Codable {
|
||||||
|
var categoryId: Int
|
||||||
|
var amount: Double
|
||||||
|
var type: String
|
||||||
|
var description: String?
|
||||||
|
var date: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case amount, type, description, date
|
||||||
|
case categoryId = "category_id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - PulseTask
|
||||||
|
|
||||||
struct PulseTask: Codable, Identifiable {
|
struct PulseTask: Codable, Identifiable {
|
||||||
let id: Int
|
let id: Int
|
||||||
var title: String
|
var title: String
|
||||||
@@ -11,6 +13,9 @@ struct PulseTask: Codable, Identifiable {
|
|||||||
var dueDate: String?
|
var dueDate: String?
|
||||||
var reminderTime: String?
|
var reminderTime: String?
|
||||||
var createdAt: String?
|
var createdAt: String?
|
||||||
|
var isRecurring: Bool?
|
||||||
|
var recurrenceType: String?
|
||||||
|
var recurrenceInterval: Int?
|
||||||
|
|
||||||
var priorityColor: String {
|
var priorityColor: String {
|
||||||
switch priority {
|
switch priority {
|
||||||
@@ -31,22 +36,80 @@ struct PulseTask: Codable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isOverdue: Bool {
|
||||||
|
guard !completed, let due = dueDate else { return false }
|
||||||
|
let dateStr = String(due.prefix(10))
|
||||||
|
let today = ISO8601DateFormatter().string(from: Date()).prefix(10)
|
||||||
|
return dateStr < today
|
||||||
|
}
|
||||||
|
|
||||||
|
var dueDateFormatted: String? {
|
||||||
|
guard let due = dueDate else { return nil }
|
||||||
|
let dateStr = String(due.prefix(10))
|
||||||
|
let todayStr = String(ISO8601DateFormatter().string(from: Date()).prefix(10))
|
||||||
|
let tomorrowDate = Calendar.current.date(byAdding: .day, value: 1, to: Date())!
|
||||||
|
let tomorrowStr = String(ISO8601DateFormatter().string(from: tomorrowDate).prefix(10))
|
||||||
|
if dateStr == todayStr { return "Сегодня" }
|
||||||
|
if dateStr == tomorrowStr { return "Завтра" }
|
||||||
|
let parts = dateStr.split(separator: "-")
|
||||||
|
guard parts.count == 3 else { return dateStr }
|
||||||
|
return "\(parts[2]).\(parts[1]).\(parts[0])"
|
||||||
|
}
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, title, description, completed, priority, icon, color
|
case id, title, description, completed, priority, icon, color
|
||||||
case dueDate = "due_date"
|
case dueDate = "due_date"
|
||||||
case reminderTime = "reminder_time"
|
case reminderTime = "reminder_time"
|
||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
|
case isRecurring = "is_recurring"
|
||||||
|
case recurrenceType = "recurrence_type"
|
||||||
|
case recurrenceInterval = "recurrence_interval"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - TaskStats
|
||||||
|
|
||||||
|
struct TaskStats: Codable {
|
||||||
|
let totalTasks: Int?
|
||||||
|
let completedTasks: Int?
|
||||||
|
let todayTasks: Int?
|
||||||
|
let todayCompleted: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case totalTasks = "total_tasks"
|
||||||
|
case completedTasks = "completed_tasks"
|
||||||
|
case todayTasks = "today_tasks"
|
||||||
|
case todayCompleted = "today_completed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CreateTaskRequest
|
||||||
|
|
||||||
struct CreateTaskRequest: Codable {
|
struct CreateTaskRequest: Codable {
|
||||||
var title: String
|
var title: String
|
||||||
var description: String?
|
var description: String?
|
||||||
var priority: Int?
|
var priority: Int?
|
||||||
var dueDate: String?
|
var dueDate: String?
|
||||||
|
var icon: String?
|
||||||
|
var color: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case title, description, priority
|
case title, description, priority, icon, color
|
||||||
|
case dueDate = "due_date"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UpdateTaskRequest
|
||||||
|
|
||||||
|
struct UpdateTaskRequest: Codable {
|
||||||
|
var title: String?
|
||||||
|
var description: String?
|
||||||
|
var priority: Int?
|
||||||
|
var dueDate: String?
|
||||||
|
var completed: Bool?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case title, description, priority, completed
|
||||||
case dueDate = "due_date"
|
case dueDate = "due_date"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
PulseHealth/Models/UserModels.swift
Normal file
41
PulseHealth/Models/UserModels.swift
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - UserProfile
|
||||||
|
|
||||||
|
struct UserProfile: Codable {
|
||||||
|
var telegramChatId: String?
|
||||||
|
var morningNotification: Bool?
|
||||||
|
var eveningNotification: Bool?
|
||||||
|
var morningTime: String?
|
||||||
|
var eveningTime: String?
|
||||||
|
var timezone: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case telegramChatId = "telegram_chat_id"
|
||||||
|
case morningNotification = "morning_notification"
|
||||||
|
case eveningNotification = "evening_notification"
|
||||||
|
case morningTime = "morning_time"
|
||||||
|
case eveningTime = "evening_time"
|
||||||
|
case timezone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UpdateProfileRequest
|
||||||
|
|
||||||
|
struct UpdateProfileRequest: Codable {
|
||||||
|
var telegramChatId: String?
|
||||||
|
var morningNotification: Bool?
|
||||||
|
var eveningNotification: Bool?
|
||||||
|
var morningTime: String?
|
||||||
|
var eveningTime: String?
|
||||||
|
var timezone: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case telegramChatId = "telegram_chat_id"
|
||||||
|
case morningNotification = "morning_notification"
|
||||||
|
case eveningNotification = "evening_notification"
|
||||||
|
case morningTime = "morning_time"
|
||||||
|
case eveningTime = "evening_time"
|
||||||
|
case timezone
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - APIError
|
||||||
|
|
||||||
enum APIError: Error, LocalizedError {
|
enum APIError: Error, LocalizedError {
|
||||||
case unauthorized
|
case unauthorized
|
||||||
case networkError(String)
|
case networkError(String)
|
||||||
@@ -8,7 +10,7 @@ enum APIError: Error, LocalizedError {
|
|||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .unauthorized: return "Неверный email или пароль"
|
case .unauthorized: return "Сессия истекла. Войдите снова."
|
||||||
case .networkError(let m): return "Ошибка сети: \(m)"
|
case .networkError(let m): return "Ошибка сети: \(m)"
|
||||||
case .decodingError(let m): return "Ошибка данных: \(m)"
|
case .decodingError(let m): return "Ошибка данных: \(m)"
|
||||||
case .serverError(let c, let m): return "Ошибка \(c): \(m)"
|
case .serverError(let c, let m): return "Ошибка \(c): \(m)"
|
||||||
@@ -16,6 +18,8 @@ enum APIError: Error, LocalizedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - APIService
|
||||||
|
|
||||||
class APIService {
|
class APIService {
|
||||||
static let shared = APIService()
|
static let shared = APIService()
|
||||||
let baseURL = "https://api.digital-home.site"
|
let baseURL = "https://api.digital-home.site"
|
||||||
@@ -39,8 +43,13 @@ class APIService {
|
|||||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown"
|
let msg = String(data: data, encoding: .utf8) ?? "Unknown"
|
||||||
throw APIError.serverError(http.statusCode, msg)
|
throw APIError.serverError(http.statusCode, msg)
|
||||||
}
|
}
|
||||||
do { return try JSONDecoder().decode(T.self, from: data) }
|
let decoder = JSONDecoder()
|
||||||
catch { throw APIError.decodingError(error.localizedDescription) }
|
do { return try decoder.decode(T.self, from: data) }
|
||||||
|
catch {
|
||||||
|
// Debug: print first 200 chars of response
|
||||||
|
let snippet = String(data: data, encoding: .utf8)?.prefix(200) ?? ""
|
||||||
|
throw APIError.decodingError("\(error.localizedDescription) | Response: \(snippet)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Auth
|
// MARK: - Auth
|
||||||
@@ -59,6 +68,17 @@ class APIService {
|
|||||||
return try await fetch("/auth/me", token: token)
|
return try await fetch("/auth/me", token: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Profile
|
||||||
|
|
||||||
|
func getProfile(token: String) async throws -> UserProfile {
|
||||||
|
return try await fetch("/profile", token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProfile(token: String, request: UpdateProfileRequest) async throws -> UserProfile {
|
||||||
|
let body = try JSONEncoder().encode(request)
|
||||||
|
return try await fetch("/profile", method: "PUT", token: token, body: body)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Tasks
|
// MARK: - Tasks
|
||||||
|
|
||||||
func getTasks(token: String) async throws -> [PulseTask] {
|
func getTasks(token: String) async throws -> [PulseTask] {
|
||||||
@@ -75,33 +95,80 @@ class APIService {
|
|||||||
return try await fetch("/tasks", method: "POST", token: token, body: body)
|
return try await fetch("/tasks", method: "POST", token: token, body: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func updateTask(token: String, id: Int, request: UpdateTaskRequest) async throws -> PulseTask {
|
||||||
|
let body = try JSONEncoder().encode(request)
|
||||||
|
return try await fetch("/tasks/\(id)", method: "PUT", token: token, body: body)
|
||||||
|
}
|
||||||
|
|
||||||
func completeTask(token: String, id: Int) async throws {
|
func completeTask(token: String, id: Int) async throws {
|
||||||
let _: EmptyResponse = try await fetch("/tasks/\(id)/complete", method: "POST", token: token)
|
let _: EmptyResponse = try await fetch("/tasks/\(id)/complete", method: "POST", token: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func uncompleteTask(token: String, id: Int) async throws {
|
||||||
|
let _: EmptyResponse = try await fetch("/tasks/\(id)/uncomplete", method: "POST", token: token)
|
||||||
|
}
|
||||||
|
|
||||||
func deleteTask(token: String, id: Int) async throws {
|
func deleteTask(token: String, id: Int) async throws {
|
||||||
let _: EmptyResponse = try await fetch("/tasks/\(id)", method: "DELETE", token: token)
|
let _: EmptyResponse = try await fetch("/tasks/\(id)", method: "DELETE", token: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Habits
|
// MARK: - Habits
|
||||||
|
|
||||||
func getHabits(token: String) async throws -> [Habit] {
|
func getHabits(token: String, includeArchived: Bool = false) async throws -> [Habit] {
|
||||||
return try await fetch("/habits", token: token)
|
let query = includeArchived ? "?archived=true" : ""
|
||||||
|
return try await fetch("/habits\(query)", token: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
func logHabit(token: String, id: Int) async throws {
|
@discardableResult
|
||||||
let body = try JSONEncoder().encode(HabitLogRequest())
|
func createHabit(token: String, body: Data) async throws -> Habit {
|
||||||
|
return try await fetch("/habits", method: "POST", token: token, body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func updateHabit(token: String, id: Int, body: Data) async throws -> Habit {
|
||||||
|
return try await fetch("/habits/\(id)", method: "PUT", token: token, body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteHabit(token: String, id: Int) async throws {
|
||||||
|
let _: EmptyResponse = try await fetch("/habits/\(id)", method: "DELETE", token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logHabit(token: String, id: Int, date: String? = nil) async throws {
|
||||||
|
var params: [String: Any] = [:]
|
||||||
|
if let d = date { params["completed_at"] = d }
|
||||||
|
let body = try JSONSerialization.data(withJSONObject: params)
|
||||||
let _: EmptyResponse = try await fetch("/habits/\(id)/log", method: "POST", token: token, body: body)
|
let _: EmptyResponse = try await fetch("/habits/\(id)/log", method: "POST", token: token, body: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func unlogHabit(token: String, habitId: Int, logId: Int) async throws {
|
||||||
|
let _: EmptyResponse = try await fetch("/habits/\(habitId)/logs/\(logId)", method: "DELETE", token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHabitLogs(token: String, habitId: Int, days: Int = 90) async throws -> [HabitLog] {
|
||||||
|
return try await fetch("/habits/\(habitId)/logs?days=\(days)", token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHabitStats(token: String, habitId: Int) async throws -> HabitStats {
|
||||||
|
return try await fetch("/habits/\(habitId)/stats", token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHabitsStats(token: String) async throws -> HabitsOverallStats {
|
||||||
|
return try await fetch("/habits/stats", token: token)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Finance
|
// MARK: - Finance
|
||||||
|
|
||||||
func getFinanceSummary(token: String) async throws -> FinanceSummary {
|
func getFinanceSummary(token: String, month: Int? = nil, year: Int? = nil) async throws -> FinanceSummary {
|
||||||
return try await fetch("/finance/summary", token: token)
|
var query = ""
|
||||||
|
if let m = month, let y = year { query = "?month=\(m)&year=\(y)" }
|
||||||
|
return try await fetch("/finance/summary\(query)", token: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTransactions(token: String) async throws -> [FinanceTransaction] {
|
func getTransactions(token: String, month: Int? = nil, year: Int? = nil) async throws -> [FinanceTransaction] {
|
||||||
return try await fetch("/finance/transactions", token: token)
|
var query = ""
|
||||||
|
if let m = month, let y = year { query = "?month=\(m)&year=\(y)" }
|
||||||
|
return try await fetch("/finance/transactions\(query)", token: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -110,22 +177,57 @@ class APIService {
|
|||||||
return try await fetch("/finance/transactions", method: "POST", token: token, body: body)
|
return try await fetch("/finance/transactions", method: "POST", token: token, body: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteTransaction(token: String, id: Int) async throws {
|
||||||
|
let _: EmptyResponse = try await fetch("/finance/transactions/\(id)", method: "DELETE", token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFinanceCategories(token: String) async throws -> [FinanceCategory] {
|
||||||
|
return try await fetch("/finance/categories", token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFinanceAnalytics(token: String, month: Int? = nil, year: Int? = nil) async throws -> FinanceAnalytics {
|
||||||
|
var query = ""
|
||||||
|
if let m = month, let y = year { query = "?month=\(m)&year=\(y)" }
|
||||||
|
return try await fetch("/finance/analytics\(query)", token: token)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Savings
|
// MARK: - Savings
|
||||||
|
|
||||||
func getSavingsCategories(token: String) async throws -> [SavingsCategory] {
|
func getSavingsCategories(token: String) async throws -> [SavingsCategory] {
|
||||||
return try await fetch("/savings/categories", token: token)
|
return try await fetch("/savings/categories", token: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func createSavingsCategory(token: String, body: Data) async throws -> SavingsCategory {
|
||||||
|
return try await fetch("/savings/categories", method: "POST", token: token, body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSavingsCategory(token: String, id: Int, body: Data) async throws {
|
||||||
|
let _: SavingsCategory = try await fetch("/savings/categories/\(id)", method: "PUT", token: token, body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteSavingsCategory(token: String, id: Int) async throws {
|
||||||
|
let _: EmptyResponse = try await fetch("/savings/categories/\(id)", method: "DELETE", token: token)
|
||||||
|
}
|
||||||
|
|
||||||
func getSavingsStats(token: String) async throws -> SavingsStats {
|
func getSavingsStats(token: String) async throws -> SavingsStats {
|
||||||
return try await fetch("/savings/stats", token: token)
|
return try await fetch("/savings/stats", token: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSavingsTransactions(token: String, limit: Int = 50) async throws -> [SavingsTransaction] {
|
func getSavingsTransactions(token: String, categoryId: Int? = nil, limit: Int = 50) async throws -> [SavingsTransaction] {
|
||||||
return try await fetch("/savings/transactions?limit=\(limit)", token: token)
|
var query = "?limit=\(limit)"
|
||||||
|
if let c = categoryId { query += "&category_id=\(c)" }
|
||||||
|
return try await fetch("/savings/transactions\(query)", token: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getFinanceCategories(token: String) async throws -> [FinanceCategory] {
|
@discardableResult
|
||||||
return try await fetch("/finance/categories", token: token)
|
func createSavingsTransaction(token: String, request: CreateSavingsTransactionRequest) async throws -> SavingsTransaction {
|
||||||
|
let body = try JSONEncoder().encode(request)
|
||||||
|
return try await fetch("/savings/transactions", method: "POST", token: token, body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteSavingsTransaction(token: String, id: Int) async throws {
|
||||||
|
let _: EmptyResponse = try await fetch("/savings/transactions/\(id)", method: "DELETE", token: token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,22 @@ import SwiftUI
|
|||||||
|
|
||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
@EnvironmentObject var authManager: AuthManager
|
@EnvironmentObject var authManager: AuthManager
|
||||||
@State private var tasks: [PulseTask] = []
|
@State private var todayTasks: [PulseTask] = []
|
||||||
@State private var habits: [Habit] = []
|
@State private var todayHabits: [Habit] = []
|
||||||
@State private var readiness: ReadinessResponse?
|
@State private var habitsStats: HabitsOverallStats?
|
||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
|
@State private var showAddSheet = false
|
||||||
|
@State private var addMode: AddMode = .task
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@State private var showError = false
|
||||||
|
|
||||||
|
// Undo state
|
||||||
|
@State private var recentlyLoggedHabitId: Int?
|
||||||
|
@State private var recentlyLoggedHabitLogDate: String?
|
||||||
|
@State private var recentlyCompletedTaskId: Int?
|
||||||
|
@State private var undoTimer: Timer?
|
||||||
|
|
||||||
|
enum AddMode { case task, habit }
|
||||||
|
|
||||||
var greeting: String {
|
var greeting: String {
|
||||||
let h = Calendar.current.component(.hour, from: Date())
|
let h = Calendar.current.component(.hour, from: Date())
|
||||||
@@ -17,162 +29,386 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var pendingTasks: [PulseTask] { tasks.filter { !$0.completed } }
|
var completedHabitsToday: Int { todayHabits.filter { $0.completedToday == true }.count }
|
||||||
var completedHabitsToday: Int { habits.filter { $0.completedToday == true }.count }
|
var totalHabitsToday: Int { todayHabits.count }
|
||||||
|
var dayProgress: Double {
|
||||||
|
guard totalHabitsToday > 0 else { return 0 }
|
||||||
|
return Double(completedHabitsToday) / Double(totalHabitsToday)
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeTodayTasks: [PulseTask] { todayTasks.filter { !$0.completed } }
|
||||||
|
var completedTodayTasksCount: Int { todayTasks.filter { $0.completed }.count }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "0a0a1a").ignoresSafeArea()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
// Header
|
// MARK: Header
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(greeting + ", " + authManager.userName + "!")
|
Text("\(greeting), \(authManager.userName)!")
|
||||||
.font(.title2.bold()).foregroundColor(.white)
|
.font(.title2.bold()).foregroundColor(.white)
|
||||||
Text(Date(), style: .date)
|
Text(Date(), style: .date)
|
||||||
.font(.subheadline).foregroundColor(Color(hex: "8888aa"))
|
.font(.subheadline).foregroundColor(Color(hex: "8888aa"))
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Logout
|
|
||||||
Button {
|
|
||||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
||||||
authManager.logout()
|
|
||||||
} label: {
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.fill(Color(hex: "1a1a3e"))
|
|
||||||
.frame(width: 42, height: 42)
|
|
||||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
|
||||||
.font(.system(size: 14, weight: .medium))
|
|
||||||
.foregroundColor(Color(hex: "8888aa"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
|
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 40)
|
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
|
||||||
} else {
|
} else {
|
||||||
// Readiness Score mini card
|
// MARK: Day Progress
|
||||||
if let r = readiness {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ReadinessMiniCard(readiness: r)
|
HStack {
|
||||||
}
|
Text("Прогресс дня")
|
||||||
|
.font(.subheadline).foregroundColor(Color(hex: "8888aa"))
|
||||||
// Stats row
|
Spacer()
|
||||||
HStack(spacing: 12) {
|
Text("\(completedHabitsToday)/\(totalHabitsToday) привычек")
|
||||||
StatCard(icon: "checkmark.circle.fill", value: "\(pendingTasks.count)", label: "Задач", color: "00d4aa")
|
.font(.caption).foregroundColor(Color(hex: "0D9488"))
|
||||||
StatCard(icon: "flame.fill", value: "\(completedHabitsToday)/\(habits.count)", label: "Привычек", color: "ffa502")
|
|
||||||
if let r = readiness {
|
|
||||||
StatCard(icon: "heart.fill", value: "\(r.score)", label: "Готовность", color: r.score >= 80 ? "00d4aa" : r.score >= 60 ? "ffa502" : "ff4757")
|
|
||||||
}
|
}
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color.white.opacity(0.1))
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .leading, endPoint: .trailing))
|
||||||
|
.frame(width: geo.size.width * dayProgress)
|
||||||
|
.animation(.easeInOut(duration: 0.5), value: dayProgress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 8)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
// Today's tasks
|
// MARK: Stat Cards
|
||||||
if !pendingTasks.isEmpty {
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
DashStatCard(icon: "checkmark.circle.fill", value: "\(completedHabitsToday)", label: "Выполнено сегодня", color: "0D9488")
|
||||||
Text("Задачи на сегодня").font(.headline).foregroundColor(.white).padding(.horizontal)
|
DashStatCard(icon: "flame.fill", value: "\(habitsStats?.activeHabits ?? totalHabitsToday)", label: "Активных привычек", color: "ffa502")
|
||||||
ForEach(pendingTasks.prefix(3)) { task in
|
DashStatCard(icon: "calendar", value: "\(todayTasks.count)", label: "Задач на сегодня", color: "6366f1")
|
||||||
TaskRowView(task: task) {
|
DashStatCard(icon: "checkmark.seal.fill", value: "\(completedTodayTasksCount)", label: "Задач выполнено", color: "10b981")
|
||||||
await completeTask(task)
|
}
|
||||||
}
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// MARK: Today's Habits
|
||||||
|
if !todayHabits.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("Привычки сегодня")
|
||||||
|
.font(.headline).foregroundColor(.white)
|
||||||
|
Spacer()
|
||||||
|
Text("\(completedHabitsToday)/\(totalHabitsToday)")
|
||||||
|
.font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
ForEach(todayHabits) { habit in
|
||||||
|
DashHabitRow(
|
||||||
|
habit: habit,
|
||||||
|
isUndoVisible: recentlyLoggedHabitId == habit.id,
|
||||||
|
onToggle: { await toggleHabit(habit) },
|
||||||
|
onUndo: { await undoHabitLog(habit) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
EmptyState(icon: "flame.fill", text: "Нет привычек на сегодня")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Habits progress
|
// MARK: Today's Tasks
|
||||||
if !habits.isEmpty {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
HStack {
|
||||||
Text("Привычки сегодня").font(.headline).foregroundColor(.white).padding(.horizontal)
|
Text("Задачи на сегодня")
|
||||||
ForEach(habits.prefix(4)) { habit in
|
.font(.headline).foregroundColor(.white)
|
||||||
HabitRowView(habit: habit) {
|
Spacer()
|
||||||
await logHabit(habit)
|
Button(action: { addMode = .task; showAddSheet = true }) {
|
||||||
}
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.foregroundColor(Color(hex: "0D9488"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
if todayTasks.isEmpty {
|
||||||
|
EmptyState(icon: "checkmark.circle", text: "Нет задач на сегодня")
|
||||||
|
} else {
|
||||||
|
ForEach(todayTasks) { task in
|
||||||
|
DashTaskRow(
|
||||||
|
task: task,
|
||||||
|
isUndoVisible: recentlyCompletedTaskId == task.id,
|
||||||
|
onToggle: { await toggleTask(task) },
|
||||||
|
onUndo: { await undoTask(task) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 20)
|
Spacer(minLength: 80)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.refreshable { await loadData(refresh: true) }
|
.refreshable { await loadData(refresh: true) }
|
||||||
|
|
||||||
|
// MARK: FAB
|
||||||
|
Button(action: { addMode = .task; showAddSheet = 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 loadData() }
|
.task { await loadData() }
|
||||||
|
.sheet(isPresented: $showAddSheet) {
|
||||||
|
if addMode == .task {
|
||||||
|
AddTaskView(isPresented: $showAddSheet) { await loadData(refresh: true) }
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationBackground(Color(hex: "0a0a1a"))
|
||||||
|
} else {
|
||||||
|
AddHabitView(isPresented: $showAddSheet) { await loadData(refresh: true) }
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationBackground(Color(hex: "0a0a1a"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Ошибка", isPresented: $showError) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: { Text(errorMessage ?? "") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Loading
|
||||||
|
|
||||||
func loadData(refresh: Bool = false) async {
|
func loadData(refresh: Bool = false) async {
|
||||||
if !refresh { isLoading = true }
|
if !refresh { isLoading = true }
|
||||||
async let t = APIService.shared.getTodayTasks(token: authManager.token)
|
async let tasks = APIService.shared.getTodayTasks(token: authManager.token)
|
||||||
async let h = APIService.shared.getHabits(token: authManager.token)
|
async let habits = APIService.shared.getHabits(token: authManager.token)
|
||||||
async let r = HealthAPIService.shared.getReadiness(apiKey: authManager.healthApiKey)
|
async let stats = APIService.shared.getHabitsStats(token: authManager.token)
|
||||||
tasks = (try? await t) ?? []
|
todayTasks = (try? await tasks) ?? []
|
||||||
habits = (try? await h) ?? []
|
todayHabits = (try? await habits) ?? []
|
||||||
readiness = try? await r
|
habitsStats = try? await stats
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func completeTask(_ task: PulseTask) async {
|
// MARK: - Actions
|
||||||
try? await APIService.shared.completeTask(token: authManager.token, id: task.id)
|
|
||||||
await loadData()
|
func toggleHabit(_ habit: Habit) async {
|
||||||
|
if habit.completedToday == true {
|
||||||
|
// Already done — undo will handle it
|
||||||
|
return
|
||||||
|
}
|
||||||
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
do {
|
||||||
|
let today = todayDateString()
|
||||||
|
try await APIService.shared.logHabit(token: authManager.token, id: habit.id, date: today)
|
||||||
|
recentlyLoggedHabitId = habit.id
|
||||||
|
recentlyLoggedHabitLogDate = today
|
||||||
|
await loadData(refresh: true)
|
||||||
|
scheduleUndoClear()
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription; showError = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func logHabit(_ habit: Habit) async {
|
func undoHabitLog(_ habit: Habit) async {
|
||||||
try? await APIService.shared.logHabit(token: authManager.token, id: habit.id)
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
await loadData()
|
// Get logs and find today's log to delete
|
||||||
|
do {
|
||||||
|
let logs = try await APIService.shared.getHabitLogs(token: authManager.token, habitId: habit.id, days: 1)
|
||||||
|
let today = todayDateString()
|
||||||
|
if let log = logs.first(where: { $0.dateOnly == today }) {
|
||||||
|
try await APIService.shared.unlogHabit(token: authManager.token, habitId: habit.id, logId: log.id)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
recentlyLoggedHabitId = nil
|
||||||
|
await loadData(refresh: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
recentlyCompletedTaskId = task.id
|
||||||
|
scheduleUndoClear()
|
||||||
|
}
|
||||||
|
await loadData(refresh: true)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription; showError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func undoTask(_ task: PulseTask) async {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
do {
|
||||||
|
try await APIService.shared.uncompleteTask(token: authManager.token, id: task.id)
|
||||||
|
} catch {}
|
||||||
|
recentlyCompletedTaskId = nil
|
||||||
|
await loadData(refresh: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleUndoClear() {
|
||||||
|
undoTimer?.invalidate()
|
||||||
|
undoTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
|
||||||
|
recentlyLoggedHabitId = nil
|
||||||
|
recentlyCompletedTaskId = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func todayDateString() -> String {
|
||||||
|
let df = DateFormatter()
|
||||||
|
df.dateFormat = "yyyy-MM-dd"
|
||||||
|
return df.string(from: Date())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - StatCard
|
// MARK: - DashStatCard
|
||||||
|
|
||||||
struct StatCard: View {
|
struct DashStatCard: View {
|
||||||
let icon: String
|
let icon: String
|
||||||
let value: String
|
let value: String
|
||||||
let label: String
|
let label: String
|
||||||
let color: String
|
let color: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 8) {
|
||||||
Image(systemName: icon).foregroundColor(Color(hex: color)).font(.title3)
|
Image(systemName: icon)
|
||||||
Text(value).font(.headline.bold()).foregroundColor(.white)
|
.foregroundColor(Color(hex: color))
|
||||||
Text(label).font(.caption).foregroundColor(Color(hex: "8888aa"))
|
.font(.title2)
|
||||||
|
Text(value)
|
||||||
|
.font(.title3.bold()).foregroundColor(.white)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(12)
|
|
||||||
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.05)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - ReadinessMiniCard
|
|
||||||
|
|
||||||
struct ReadinessMiniCard: View {
|
|
||||||
let readiness: ReadinessResponse
|
|
||||||
|
|
||||||
var statusColor: Color {
|
|
||||||
readiness.score >= 80 ? Color(hex: "00d4aa") :
|
|
||||||
readiness.score >= 60 ? Color(hex: "ffa502") :
|
|
||||||
Color(hex: "ff4757")
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 16) {
|
|
||||||
ZStack {
|
|
||||||
Circle().stroke(Color.white.opacity(0.1), lineWidth: 6).frame(width: 60, height: 60)
|
|
||||||
Circle().trim(from: 0, to: CGFloat(readiness.score) / 100)
|
|
||||||
.stroke(statusColor, style: StrokeStyle(lineWidth: 6, lineCap: .round))
|
|
||||||
.frame(width: 60, height: 60).rotationEffect(.degrees(-90))
|
|
||||||
Text("\(readiness.score)").font(.headline.bold()).foregroundColor(statusColor)
|
|
||||||
}
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text("Готовность").font(.subheadline).foregroundColor(Color(hex: "8888aa"))
|
|
||||||
Text(readiness.recommendation).font(.callout).foregroundColor(.white).lineLimit(2)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.05)))
|
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.05)))
|
||||||
.padding(.horizontal)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DashHabitRow
|
||||||
|
|
||||||
|
struct DashHabitRow: View {
|
||||||
|
let habit: Habit
|
||||||
|
let isUndoVisible: Bool
|
||||||
|
let onToggle: () async -> Void
|
||||||
|
let onUndo: () 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(isDone ? 0.3 : 0.1)).frame(width: 44, height: 44)
|
||||||
|
Text(habit.displayIcon).font(.title3)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(habit.name)
|
||||||
|
.font(.callout.weight(.medium)).foregroundColor(.white)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(habit.frequencyLabel).font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
if let streak = habit.currentStreak, streak > 0 {
|
||||||
|
Text("🔥 \(streak)").font(.caption).foregroundColor(Color(hex: "ffa502"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if isUndoVisible {
|
||||||
|
Button(action: { Task { await onUndo() } }) {
|
||||||
|
Text("Отмена").font(.caption.bold())
|
||||||
|
.foregroundColor(Color(hex: "ffa502"))
|
||||||
|
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 8).fill(Color(hex: "ffa502").opacity(0.15)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(action: { guard !isDone else { return }; Task { await onToggle() } }) {
|
||||||
|
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(isDone ? accentColor : Color(hex: "8888aa"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(isDone ? accentColor.opacity(0.08) : Color.white.opacity(0.04))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 16).stroke(isDone ? accentColor.opacity(0.3) : Color.clear, lineWidth: 1))
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DashTaskRow
|
||||||
|
|
||||||
|
struct DashTaskRow: View {
|
||||||
|
let task: PulseTask
|
||||||
|
let isUndoVisible: Bool
|
||||||
|
let onToggle: () async -> Void
|
||||||
|
let onUndo: () 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)
|
||||||
|
.foregroundColor(task.completed ? Color(hex: "8888aa") : .white)
|
||||||
|
.strikethrough(task.completed)
|
||||||
|
.font(.callout)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if let due = task.dueDateFormatted {
|
||||||
|
Text(due)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(task.isOverdue ? Color(hex: "ff4757") : Color(hex: "ffa502"))
|
||||||
|
}
|
||||||
|
if let p = task.priority, p > 1 {
|
||||||
|
Circle().fill(Color(hex: task.priorityColor)).frame(width: 6, height: 6)
|
||||||
|
}
|
||||||
|
if task.isRecurring == true {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if isUndoVisible {
|
||||||
|
Button(action: { Task { await onUndo() } }) {
|
||||||
|
Text("Отмена").font(.caption.bold())
|
||||||
|
.foregroundColor(Color(hex: "ffa502"))
|
||||||
|
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 8).fill(Color(hex: "ffa502").opacity(0.15)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05)))
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - EmptyState (reusable)
|
||||||
|
|
||||||
|
struct EmptyState: View {
|
||||||
|
let icon: String
|
||||||
|
let text: String
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: icon).font(.system(size: 32)).foregroundColor(Color(hex: "334155"))
|
||||||
|
Text(text).font(.subheadline).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 24)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,128 +1,483 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
// MARK: - FinanceView
|
||||||
|
|
||||||
struct FinanceView: View {
|
struct FinanceView: View {
|
||||||
@EnvironmentObject var authManager: AuthManager
|
@EnvironmentObject var authManager: AuthManager
|
||||||
@State private var summary: FinanceSummary?
|
@State private var selectedTab = 0
|
||||||
@State private var transactions: [FinanceTransaction] = []
|
@State private var selectedMonth = Calendar.current.component(.month, from: Date())
|
||||||
@State private var categories: [FinanceCategory] = []
|
@State private var selectedYear = Calendar.current.component(.year, from: Date())
|
||||||
@State private var isLoading = true
|
|
||||||
@State private var showAddTransaction = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "0a0a1a").ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
// Header with month picker
|
||||||
HStack {
|
HStack {
|
||||||
Text("Финансы").font(.title.bold()).foregroundColor(.white)
|
Text("Финансы").font(.title.bold()).foregroundColor(.white)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: { showAddTransaction = true }) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "plus.circle.fill").font(.title2).foregroundColor(Color(hex: "00d4aa"))
|
Button(action: { prevMonth() }) {
|
||||||
|
Image(systemName: "chevron.left").foregroundColor(Color(hex: "8888aa"))
|
||||||
|
}
|
||||||
|
Text(monthLabel())
|
||||||
|
.font(.subheadline).foregroundColor(.white)
|
||||||
|
.frame(minWidth: 80)
|
||||||
|
Button(action: { nextMonth() }) {
|
||||||
|
Image(systemName: "chevron.right").foregroundColor(Color(hex: "8888aa"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.padding()
|
}
|
||||||
|
.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: FinanceOverviewTab(month: selectedMonth, year: selectedYear)
|
||||||
|
case 1: FinanceTransactionsTab(month: selectedMonth, year: selectedYear)
|
||||||
|
default: FinanceAnalyticsTab(month: selectedMonth, year: selectedYear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func monthLabel() -> String {
|
||||||
|
let df = DateFormatter()
|
||||||
|
df.dateFormat = "LLLL yyyy"
|
||||||
|
df.locale = Locale(identifier: "ru_RU")
|
||||||
|
var comps = DateComponents(); comps.month = selectedMonth; comps.year = selectedYear
|
||||||
|
if let d = Calendar.current.date(from: comps) { return df.string(from: d) }
|
||||||
|
return "\(selectedMonth)/\(selectedYear)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func prevMonth() {
|
||||||
|
if selectedMonth == 1 { selectedMonth = 12; selectedYear -= 1 }
|
||||||
|
else { selectedMonth -= 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextMonth() {
|
||||||
|
if selectedMonth == 12 { selectedMonth = 1; selectedYear += 1 }
|
||||||
|
else { selectedMonth += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - FinanceOverviewTab
|
||||||
|
|
||||||
|
struct FinanceOverviewTab: View {
|
||||||
|
@EnvironmentObject var authManager: AuthManager
|
||||||
|
let month: Int
|
||||||
|
let year: Int
|
||||||
|
@State private var summary: FinanceSummary?
|
||||||
|
@State private var categories: [FinanceCategory] = []
|
||||||
|
@State private var isLoading = true
|
||||||
|
|
||||||
|
var expenseByCategory: [CategorySpend] {
|
||||||
|
(summary?.byCategory ?? []).filter { ($0.total ?? 0) > 0 }.sorted { ($0.total ?? 0) > ($1.total ?? 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var dailyPoints: [DailySpend] { summary?.daily ?? [] }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 40)
|
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
|
||||||
Spacer()
|
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
// Summary Card
|
||||||
VStack(spacing: 16) {
|
if let s = summary { FinanceSummaryCard2(summary: s) }
|
||||||
// Summary card
|
|
||||||
if let s = summary {
|
|
||||||
FinanceSummaryCard(summary: s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recent transactions
|
// Top Expenses
|
||||||
if !transactions.isEmpty {
|
if !expenseByCategory.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Последние операции")
|
Text("Топ расходов").font(.subheadline.bold()).foregroundColor(.white)
|
||||||
.font(.headline).foregroundColor(.white).padding(.horizontal)
|
ForEach(expenseByCategory.prefix(5)) { cat in
|
||||||
ForEach(transactions.prefix(20)) { tx in
|
let total = summary?.totalExpense ?? 1
|
||||||
TransactionRowView(transaction: tx, categories: categories)
|
let pct = (cat.total ?? 0) / max(total, 1)
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(cat.icon ?? "💸").font(.subheadline)
|
||||||
|
Text(cat.categoryName ?? "—").font(.callout).foregroundColor(.white)
|
||||||
|
Spacer()
|
||||||
|
Text(formatAmt(cat.total ?? 0)).font(.callout.bold()).foregroundColor(Color(hex: "ff4757"))
|
||||||
|
Text("\(Int(pct * 100))%").font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
}
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.07))
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(Color(hex: "ff4757").opacity(0.7))
|
||||||
|
.frame(width: geo.size.width * CGFloat(pct))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pie Chart
|
||||||
|
if expenseByCategory.count > 1 {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Расходы по категориям").font(.subheadline.bold()).foregroundColor(.white)
|
||||||
|
Chart(expenseByCategory) { cat in
|
||||||
|
SectorMark(
|
||||||
|
angle: .value("Сумма", cat.total ?? 0),
|
||||||
|
innerRadius: .ratio(0.55),
|
||||||
|
angularInset: 2
|
||||||
|
)
|
||||||
|
.foregroundStyle(by: .value("Кат.", cat.categoryName ?? "—"))
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
.frame(height: 200)
|
||||||
|
.chartForegroundStyleScale(range: Gradient(colors: [
|
||||||
|
Color(hex: "0D9488"), Color(hex: "6366f1"), Color(hex: "f59e0b"),
|
||||||
|
Color(hex: "ec4899"), Color(hex: "14b8a6"), Color(hex: "8b5cf6")
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily Line Chart
|
||||||
|
if !dailyPoints.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Ежедневные траты").font(.subheadline.bold()).foregroundColor(.white)
|
||||||
|
let df: DateFormatter = {
|
||||||
|
let d = DateFormatter(); d.dateFormat = "yyyy-MM-dd"; return d
|
||||||
|
}()
|
||||||
|
Chart(dailyPoints.compactMap { p -> (Date, Double)? in
|
||||||
|
guard let d = df.date(from: p.date) else { return nil }
|
||||||
|
return (d, p.expense ?? p.total ?? 0)
|
||||||
|
}, id: \.0) { item in
|
||||||
|
AreaMark(x: .value("День", item.0), y: .value("Сумма", item.1))
|
||||||
|
.foregroundStyle(LinearGradient(colors: [Color(hex: "ff4757").opacity(0.4), Color.clear], startPoint: .top, endPoint: .bottom))
|
||||||
|
LineMark(x: .value("День", item.0), y: .value("Сумма", item.1))
|
||||||
|
.foregroundStyle(Color(hex: "ff4757"))
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||||
|
}
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks(values: .stride(by: .day, count: 5)) { _ in
|
||||||
|
AxisValueLabel(format: .dateTime.day()).foregroundStyle(Color(hex: "8888aa"))
|
||||||
|
AxisGridLine().foregroundStyle(Color.white.opacity(0.05))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYAxis {
|
||||||
|
AxisMarks { v in
|
||||||
|
AxisGridLine().foregroundStyle(Color.white.opacity(0.05))
|
||||||
|
AxisValueLabel().foregroundStyle(Color(hex: "8888aa"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 140)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 80)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.task { await load() }
|
||||||
|
.onChange(of: month) { _ in Task { await load() } }
|
||||||
|
.onChange(of: year) { _ in Task { await load() } }
|
||||||
|
.refreshable { await load(refresh: true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(refresh: Bool = false) async {
|
||||||
|
if !refresh { isLoading = true }
|
||||||
|
async let s = APIService.shared.getFinanceSummary(token: authManager.token, month: month, year: year)
|
||||||
|
async let c = APIService.shared.getFinanceCategories(token: authManager.token)
|
||||||
|
summary = try? await s
|
||||||
|
categories = (try? await c) ?? []
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAmt(_ v: Double) -> String {
|
||||||
|
v >= 1000 ? String(format: "%.0f ₽", v) : String(format: "%.0f ₽", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - FinanceSummaryCard2
|
||||||
|
|
||||||
|
struct FinanceSummaryCard2: View {
|
||||||
|
let summary: FinanceSummary
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Баланс месяца").font(.subheadline).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
Text(formatAmt(summary.balance ?? 0))
|
||||||
|
.font(.system(size: 34, weight: .bold))
|
||||||
|
.foregroundColor((summary.balance ?? 0) >= 0 ? Color(hex: "0D9488") : Color(hex: "ff4757"))
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Доходы").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
Text("+\(formatAmt(summary.totalIncome ?? 0))")
|
||||||
|
.font(.callout.bold()).foregroundColor(Color(hex: "0D9488"))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Расходы").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
Text("-\(formatAmt(summary.totalExpense ?? 0))")
|
||||||
|
.font(.callout.bold()).foregroundColor(Color(hex: "ff4757"))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Перенос").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
Text("\(formatAmt(summary.carriedOver ?? 0))")
|
||||||
|
.font(.callout.bold()).foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background(
|
||||||
|
LinearGradient(colors: [Color(hex: "1a1a3e"), Color(hex: "12122a")], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||||
|
)
|
||||||
|
.cornerRadius(20)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "0D9488").opacity(0.3), lineWidth: 1))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - FinanceTransactionsTab
|
||||||
|
|
||||||
|
struct FinanceTransactionsTab: View {
|
||||||
|
@EnvironmentObject var authManager: AuthManager
|
||||||
|
let month: Int
|
||||||
|
let year: Int
|
||||||
|
@State private var transactions: [FinanceTransaction] = []
|
||||||
|
@State private var categories: [FinanceCategory] = []
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var showAdd = false
|
||||||
|
|
||||||
|
var groupedByDay: [(key: String, value: [FinanceTransaction])] {
|
||||||
|
let grouped = Dictionary(grouping: transactions) { $0.dateOnly }
|
||||||
|
return grouped.sorted { $0.key > $1.key }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottomTrailing) {
|
||||||
|
Group {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
|
||||||
|
Spacer()
|
||||||
|
} else if transactions.isEmpty {
|
||||||
|
VStack { EmptyState(icon: "creditcard", text: "Нет транзакций"); Spacer() }
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(groupedByDay, id: \.key) { section in
|
||||||
|
Section(header:
|
||||||
|
Text(formatSectionDate(section.key))
|
||||||
|
.font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
) {
|
||||||
|
ForEach(section.value) { tx in
|
||||||
|
FinanceTxRow(transaction: tx, categories: categories)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
.onDelete { idx in
|
||||||
|
let toDelete = idx.map { section.value[$0] }
|
||||||
|
Task {
|
||||||
|
for tx in toDelete { try? await APIService.shared.deleteTransaction(token: authManager.token, id: tx.id) }
|
||||||
|
await load(refresh: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.refreshable { await load(refresh: true) }
|
||||||
|
|
||||||
|
Button(action: { showAdd = 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)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showAddTransaction) {
|
.task { await load() }
|
||||||
AddTransactionView(isPresented: $showAddTransaction, categories: categories) { await loadData() }
|
.onChange(of: month) { _ in Task { await load() } }
|
||||||
|
.onChange(of: year) { _ in Task { await load() } }
|
||||||
|
.sheet(isPresented: $showAdd) {
|
||||||
|
AddTransactionView(isPresented: $showAdd, categories: categories) { await load(refresh: true) }
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.medium, .large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
.presentationBackground(Color(hex: "0a0a1a"))
|
.presentationBackground(Color(hex: "0a0a1a"))
|
||||||
}
|
}
|
||||||
.task { await loadData() }
|
|
||||||
.refreshable { await loadData(refresh: true) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadData(refresh: Bool = false) async {
|
func load(refresh: Bool = false) async {
|
||||||
if !refresh { isLoading = true }
|
if !refresh { isLoading = true }
|
||||||
async let s = APIService.shared.getFinanceSummary(token: authManager.token)
|
async let t = APIService.shared.getTransactions(token: authManager.token, month: month, year: year)
|
||||||
async let t = APIService.shared.getTransactions(token: authManager.token)
|
|
||||||
async let c = APIService.shared.getFinanceCategories(token: authManager.token)
|
async let c = APIService.shared.getFinanceCategories(token: authManager.token)
|
||||||
summary = try? await s
|
|
||||||
transactions = (try? await t) ?? []
|
transactions = (try? await t) ?? []
|
||||||
categories = (try? await c) ?? []
|
categories = (try? await c) ?? []
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - FinanceSummaryCard
|
func formatSectionDate(_ s: String) -> String {
|
||||||
|
let parts = s.split(separator: "-")
|
||||||
struct FinanceSummaryCard: View {
|
guard parts.count == 3 else { return s }
|
||||||
let summary: FinanceSummary
|
return "\(parts[2]).\(parts[1]).\(parts[0])"
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
HStack(spacing: 20) {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Text("Доходы").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
|
||||||
Text("+\(Int(summary.totalIncome ?? 0))₽").font(.headline).foregroundColor(Color(hex: "00d4aa"))
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Text("Баланс").font(.subheadline).foregroundColor(Color(hex: "8888aa"))
|
|
||||||
Text("\(Int(summary.balance ?? 0))₽").font(.title2.bold()).foregroundColor(.white)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Text("Расходы").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
|
||||||
Text("-\(Int(summary.totalExpense ?? 0))₽").font(.headline).foregroundColor(Color(hex: "ff4757"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(20)
|
|
||||||
.background(RoundedRectangle(cornerRadius: 20).fill(Color.white.opacity(0.05)))
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - TransactionRowView
|
// MARK: - FinanceTxRow
|
||||||
|
|
||||||
struct TransactionRowView: View {
|
struct FinanceTxRow: View {
|
||||||
let transaction: FinanceTransaction
|
let transaction: FinanceTransaction
|
||||||
let categories: [FinanceCategory]
|
let categories: [FinanceCategory]
|
||||||
|
var cat: FinanceCategory? { categories.first { $0.id == transaction.categoryId } }
|
||||||
var category: FinanceCategory? { categories.first { $0.id == transaction.categoryId } }
|
|
||||||
var isIncome: Bool { transaction.type == "income" }
|
var isIncome: Bool { transaction.type == "income" }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack(spacing: 12) {
|
||||||
Text(category?.icon ?? (isIncome ? "💰" : "💸")).font(.title2)
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill((isIncome ? Color(hex: "0D9488") : Color(hex: "ff4757")).opacity(0.12))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
Text(cat?.icon ?? (isIncome ? "💰" : "💸")).font(.title3)
|
||||||
|
}
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(transaction.description ?? category?.name ?? "Операция")
|
Text(transaction.description ?? cat?.name ?? "Операция")
|
||||||
.font(.callout).foregroundColor(.white)
|
.font(.callout).foregroundColor(.white)
|
||||||
Text(transaction.date ?? "").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
Text(transaction.dateFormatted).font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(isIncome ? "+" : "-")\(Int(transaction.amount))₽")
|
Text("\(isIncome ? "+" : "-")\(formatAmt(transaction.amount))")
|
||||||
.font(.callout.bold())
|
.font(.callout.bold())
|
||||||
.foregroundColor(isIncome ? Color(hex: "00d4aa") : Color(hex: "ff4757"))
|
.foregroundColor(isIncome ? Color(hex: "0D9488") : Color(hex: "ff4757"))
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.04)))
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.04)))
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
|
func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - FinanceAnalyticsTab
|
||||||
|
|
||||||
|
struct FinanceAnalyticsTab: View {
|
||||||
|
@EnvironmentObject var authManager: AuthManager
|
||||||
|
let month: Int
|
||||||
|
let year: Int
|
||||||
|
@State private var analytics: FinanceAnalytics?
|
||||||
|
@State private var isLoading = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
|
||||||
|
} else {
|
||||||
|
// Bar chart by category
|
||||||
|
if let cats = analytics?.byCategory, !cats.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Расходы по категориям").font(.subheadline.bold()).foregroundColor(.white)
|
||||||
|
Chart(cats.sorted { ($0.total ?? 0) > ($1.total ?? 0) }.prefix(8).map { $0 }) { cat in
|
||||||
|
BarMark(
|
||||||
|
x: .value("Сумма", cat.total ?? 0),
|
||||||
|
y: .value("Кат.", cat.categoryName ?? "—")
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color(hex: "ff4757"))
|
||||||
|
.cornerRadius(4)
|
||||||
|
.annotation(position: .trailing) {
|
||||||
|
Text(formatAmt(cat.total ?? 0)).font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks { _ in AxisGridLine().foregroundStyle(Color.white.opacity(0.05)) }
|
||||||
|
}
|
||||||
|
.frame(height: 250)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Month comparison
|
||||||
|
if let cur = analytics?.currentMonth, let prev = analytics?.previousMonth {
|
||||||
|
MonthComparisonCard(current: cur, previous: prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
if analytics?.byCategory == nil && analytics?.currentMonth == nil {
|
||||||
|
EmptyState(icon: "chart.bar", text: "Нет данных для аналитики")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 80)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.task { await load() }
|
||||||
|
.onChange(of: month) { _ in Task { await load() } }
|
||||||
|
.onChange(of: year) { _ in Task { await load() } }
|
||||||
|
.refreshable { await load(refresh: true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(refresh: Bool = false) async {
|
||||||
|
if !refresh { isLoading = true }
|
||||||
|
analytics = try? await APIService.shared.getFinanceAnalytics(token: authManager.token, month: month, year: year)
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAmt(_ v: Double) -> String { v >= 1000 ? String(format: "%.0f", v) : String(format: "%.0f", v) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MonthComparisonCard
|
||||||
|
|
||||||
|
struct MonthComparisonCard: View {
|
||||||
|
let current: FinanceSummary
|
||||||
|
let previous: FinanceSummary
|
||||||
|
var diff: Double { (current.totalExpense ?? 0) - (previous.totalExpense ?? 0) }
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Сравнение с прошлым месяцем").font(.subheadline.bold()).foregroundColor(.white)
|
||||||
|
HStack {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Этот месяц").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
Text(formatAmt(current.totalExpense ?? 0)).font(.headline.bold()).foregroundColor(Color(hex: "ff4757"))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Прошлый").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
Text(formatAmt(previous.totalExpense ?? 0)).font(.headline.bold()).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Изменение").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
Text("\(diff > 0 ? "+" : "")\(formatAmt(diff))").font(.headline.bold())
|
||||||
|
.foregroundColor(diff > 0 ? Color(hex: "ff4757") : Color(hex: "0D9488"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,52 +4,50 @@ struct AddHabitView: View {
|
|||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@EnvironmentObject var authManager: AuthManager
|
@EnvironmentObject var authManager: AuthManager
|
||||||
let onAdded: () async -> Void
|
let onAdded: () async -> Void
|
||||||
|
|
||||||
@State private var name = ""
|
@State private var name = ""
|
||||||
@State private var description = ""
|
@State private var description = ""
|
||||||
@State private var frequency = "daily"
|
@State private var frequency = "daily"
|
||||||
@State private var selectedIcon = "🔥"
|
@State private var selectedIcon = "🔥"
|
||||||
@State private var selectedColor = "#00d4aa"
|
@State private var selectedColor = "#0D9488"
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
|
@State private var intervalDays = "2"
|
||||||
|
@State private var selectedWeekdays: Set<Int> = [1,2,3,4,5] // Mon-Fri
|
||||||
|
|
||||||
let frequencies: [(String, String, String)] = [
|
let frequencies: [(String, String, String)] = [
|
||||||
("daily", "Каждый день", "calendar"),
|
("daily", "Каждый день", "calendar"),
|
||||||
("weekly", "Каждую неделю", "calendar.badge.clock"),
|
("weekly", "По дням недели", "calendar.badge.clock"),
|
||||||
|
("interval", "Каждые N дней", "repeat"),
|
||||||
("monthly", "Каждый месяц", "calendar.badge.plus")
|
("monthly", "Каждый месяц", "calendar.badge.plus")
|
||||||
]
|
]
|
||||||
|
|
||||||
let icons = ["🔥", "💪", "🏃", "📚", "💧", "🧘", "🎯", "⭐️", "🌟", "✅", "🏋️", "🚴", "🍎", "😴", "🧠", "🎨", "🎵", "💊", "🌿", "💰"]
|
let weekdayNames = ["Вс","Пн","Вт","Ср","Чт","Пт","Сб"]
|
||||||
|
|
||||||
let colors = ["#00d4aa", "#7c3aed", "#ff4757", "#ffa502", "#6366f1", "#ec4899", "#14b8a6", "#f59e0b", "#10b981", "#3b82f6"]
|
let icons = ["🔥", "💪", "🏃", "📚", "💧", "🧘", "🎯", "⭐️", "🌟", "✅",
|
||||||
|
"🏋️", "🚴", "🍎", "😴", "🧠", "🎨", "🎵", "💊", "🌿", "💰",
|
||||||
|
"✍️", "🧹", "🏊", "🚶", "🎮", "📝", "🌅", "🥗", "🧃", "🫁"]
|
||||||
|
|
||||||
|
let colors = ["#0D9488", "#7c3aed", "#ff4757", "#ffa502", "#6366f1",
|
||||||
|
"#ec4899", "#14b8a6", "#f59e0b", "#10b981", "#3b82f6"]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "0a0a1a").ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Handle
|
|
||||||
RoundedRectangle(cornerRadius: 3)
|
RoundedRectangle(cornerRadius: 3)
|
||||||
.fill(Color.white.opacity(0.2))
|
.fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
||||||
.frame(width: 40, height: 4)
|
|
||||||
.padding(.top, 12)
|
|
||||||
|
|
||||||
// Header
|
|
||||||
HStack {
|
HStack {
|
||||||
Button("Отмена") { isPresented = false }
|
Button("Отмена") { isPresented = false }.foregroundColor(Color(hex: "8888aa"))
|
||||||
.foregroundColor(Color(hex: "8888aa"))
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("Новая привычка").font(.headline).foregroundColor(.white)
|
Text("Новая привычка").font(.headline).foregroundColor(.white)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: save) {
|
Button(action: save) {
|
||||||
if isLoading { ProgressView().tint(Color(hex: "00d4aa")).scaleEffect(0.8) }
|
if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) }
|
||||||
else { Text("Добавить").foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Color(hex: "00d4aa")).fontWeight(.semibold) }
|
else { Text("Добавить").foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) }
|
||||||
}
|
}.disabled(name.isEmpty || isLoading)
|
||||||
.disabled(name.isEmpty || isLoading)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20).padding(.vertical, 16)
|
.padding(.horizontal, 20).padding(.vertical, 16)
|
||||||
|
|
||||||
Divider().background(Color.white.opacity(0.1))
|
Divider().background(Color.white.opacity(0.1))
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
// Preview
|
// Preview
|
||||||
@@ -64,13 +62,14 @@ struct AddHabitView: View {
|
|||||||
Text(name.isEmpty ? "Название привычки" : name)
|
Text(name.isEmpty ? "Название привычки" : name)
|
||||||
.font(.callout.bold())
|
.font(.callout.bold())
|
||||||
.foregroundColor(name.isEmpty ? Color(hex: "8888aa") : .white)
|
.foregroundColor(name.isEmpty ? Color(hex: "8888aa") : .white)
|
||||||
Text(frequencies.first { $0.0 == frequency }?.1 ?? "").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
Text(frequencies.first { $0.0 == frequency }?.1 ?? "")
|
||||||
|
.font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.05)))
|
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.05)))
|
||||||
|
|
||||||
// Name
|
// Name
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label("Название", systemImage: "pencil").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
Label("Название", systemImage: "pencil").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
@@ -78,7 +77,7 @@ struct AddHabitView: View {
|
|||||||
.foregroundColor(.white).padding(14)
|
.foregroundColor(.white).padding(14)
|
||||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frequency
|
// Frequency
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label("Периодичность", systemImage: "calendar").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
Label("Периодичность", systemImage: "calendar").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
@@ -86,35 +85,62 @@ struct AddHabitView: View {
|
|||||||
ForEach(frequencies, id: \.0) { f in
|
ForEach(frequencies, id: \.0) { f in
|
||||||
Button(action: { frequency = f.0 }) {
|
Button(action: { frequency = f.0 }) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: f.2).foregroundColor(frequency == f.0 ? Color(hex: "00d4aa") : Color(hex: "8888aa"))
|
Image(systemName: f.2).foregroundColor(frequency == f.0 ? Color(hex: "0D9488") : Color(hex: "8888aa"))
|
||||||
Text(f.1).foregroundColor(frequency == f.0 ? .white : Color(hex: "8888aa"))
|
Text(f.1).foregroundColor(frequency == f.0 ? .white : Color(hex: "8888aa"))
|
||||||
Spacer()
|
Spacer()
|
||||||
if frequency == f.0 {
|
if frequency == f.0 { Image(systemName: "checkmark").foregroundColor(Color(hex: "0D9488")) }
|
||||||
Image(systemName: "checkmark").foregroundColor(Color(hex: "00d4aa"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.background(RoundedRectangle(cornerRadius: 12).fill(frequency == f.0 ? Color(hex: "00d4aa").opacity(0.15) : Color.white.opacity(0.05)))
|
.background(RoundedRectangle(cornerRadius: 12).fill(frequency == f.0 ? Color(hex: "0D9488").opacity(0.15) : Color.white.opacity(0.05)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Weekly day selector
|
||||||
|
if frequency == "weekly" {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(0..<7) { i in
|
||||||
|
Button(action: {
|
||||||
|
if selectedWeekdays.contains(i) { if selectedWeekdays.count > 1 { selectedWeekdays.remove(i) } }
|
||||||
|
else { selectedWeekdays.insert(i) }
|
||||||
|
}) {
|
||||||
|
Text(weekdayNames[i])
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundColor(selectedWeekdays.contains(i) ? .black : .white)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(Circle().fill(selectedWeekdays.contains(i) ? Color(hex: "0D9488") : Color.white.opacity(0.08)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Interval days
|
||||||
|
if frequency == "interval" {
|
||||||
|
HStack {
|
||||||
|
Text("Каждые").foregroundColor(Color(hex: "8888aa")).font(.callout)
|
||||||
|
TextField("2", text: $intervalDays).keyboardType(.numberPad)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 50)
|
||||||
|
.padding(10)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07)))
|
||||||
|
Text("дней").foregroundColor(Color(hex: "8888aa")).font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Icon picker
|
// Icon picker
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label("Иконка", systemImage: "face.smiling").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
Label("Иконка", systemImage: "face.smiling").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 8) {
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: 8) {
|
||||||
ForEach(icons, id: \.self) { icon in
|
ForEach(icons, id: \.self) { icon in
|
||||||
Button(action: { selectedIcon = icon }) {
|
Button(action: { selectedIcon = icon }) {
|
||||||
Text(icon).font(.title2)
|
Text(icon).font(.title3)
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 40, height: 40)
|
||||||
.background(Circle().fill(selectedIcon == icon ? Color(hex: "00d4aa").opacity(0.25) : Color.white.opacity(0.05)))
|
.background(Circle().fill(selectedIcon == icon ? Color(hex: "0D9488").opacity(0.25) : Color.white.opacity(0.05)))
|
||||||
.overlay(Circle().stroke(selectedIcon == icon ? Color(hex: "00d4aa") : Color.clear, lineWidth: 2))
|
.overlay(Circle().stroke(selectedIcon == icon ? Color(hex: "0D9488") : Color.clear, lineWidth: 2))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color picker
|
// Color picker
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label("Цвет", systemImage: "paintpalette").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
Label("Цвет", systemImage: "paintpalette").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
@@ -131,21 +157,16 @@ struct AddHabitView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}.padding(20)
|
||||||
.padding(20)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func save() {
|
func save() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
Task {
|
Task {
|
||||||
var req = URLRequest(url: URL(string: "https://api.digital-home.site/habits")!)
|
var body: [String: Any] = [
|
||||||
req.httpMethod = "POST"
|
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
req.setValue("Bearer \(authManager.token)", forHTTPHeaderField: "Authorization")
|
|
||||||
let body: [String: Any] = [
|
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": description,
|
"description": description,
|
||||||
"frequency": frequency,
|
"frequency": frequency,
|
||||||
@@ -153,8 +174,14 @@ struct AddHabitView: View {
|
|||||||
"color": selectedColor,
|
"color": selectedColor,
|
||||||
"target_count": 1
|
"target_count": 1
|
||||||
]
|
]
|
||||||
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
if frequency == "weekly" {
|
||||||
_ = try? await URLSession.shared.data(for: req)
|
body["target_days"] = Array(selectedWeekdays).sorted()
|
||||||
|
}
|
||||||
|
if frequency == "interval" {
|
||||||
|
body["target_count"] = Int(intervalDays) ?? 2
|
||||||
|
}
|
||||||
|
let reqBody = try? JSONSerialization.data(withJSONObject: body)
|
||||||
|
try? await APIService.shared.createHabit(token: authManager.token, body: reqBody ?? Data())
|
||||||
await onAdded()
|
await onAdded()
|
||||||
await MainActor.run { isPresented = false }
|
await MainActor.run { isPresented = false }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,29 +4,25 @@ struct HabitRowView: View {
|
|||||||
let habit: Habit
|
let habit: Habit
|
||||||
let onLog: () async -> Void
|
let onLog: () async -> Void
|
||||||
|
|
||||||
var accentColor: Color { Color(hex: habit.color ?? "00d4aa") }
|
var accentColor: Color { Color(hex: habit.accentColorHex.replacingOccurrences(of: "#", with: "")) }
|
||||||
var isDone: Bool { habit.completedToday == true }
|
var isDone: Bool { habit.completedToday == true }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
// Icon
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle().fill(accentColor.opacity(isDone ? 0.3 : 0.1)).frame(width: 44, height: 44)
|
Circle().fill(accentColor.opacity(isDone ? 0.3 : 0.1)).frame(width: 44, height: 44)
|
||||||
Text(habit.icon ?? "🔥").font(.title3)
|
Text(habit.displayIcon).font(.title3)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(habit.name).font(.callout.weight(.medium)).foregroundColor(.white)
|
Text(habit.name).font(.callout.weight(.medium)).foregroundColor(.white)
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text(habit.frequency.displayName).font(.caption).foregroundColor(Color(hex: "8888aa"))
|
Text(habit.frequencyLabel).font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
if let streak = habit.currentStreak, streak > 0 {
|
if let streak = habit.currentStreak, streak > 0 {
|
||||||
Text("🔥 \(streak)").font(.caption).foregroundColor(Color(hex: "ffa502"))
|
Text("🔥 \(streak)").font(.caption).foregroundColor(Color(hex: "ffa502"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: { guard !isDone else { return }; Task { await onLog() } }) {
|
Button(action: { guard !isDone else { return }; Task { await onLog() } }) {
|
||||||
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
|
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// HabitsView is now used by TrackerView (HabitListView).
|
||||||
|
// This file kept for backward compat but MainTabView uses TrackerView.
|
||||||
struct HabitsView: View {
|
struct HabitsView: View {
|
||||||
@EnvironmentObject var authManager: AuthManager
|
@EnvironmentObject var authManager: AuthManager
|
||||||
@State private var habits: [Habit] = []
|
@State private var habits: [Habit] = []
|
||||||
@@ -19,43 +21,36 @@ struct HabitsView: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: { showAddHabit = true }) {
|
Button(action: { showAddHabit = true }) {
|
||||||
Image(systemName: "plus.circle.fill").font(.title2).foregroundColor(Color(hex: "00d4aa"))
|
Image(systemName: "plus.circle.fill").font(.title2).foregroundColor(Color(hex: "0D9488"))
|
||||||
}
|
}
|
||||||
}.padding()
|
}.padding()
|
||||||
|
|
||||||
// Progress bar
|
|
||||||
if !habits.isEmpty {
|
if !habits.isEmpty {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.1))
|
RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.1))
|
||||||
RoundedRectangle(cornerRadius: 4).fill(Color(hex: "00d4aa"))
|
RoundedRectangle(cornerRadius: 4).fill(Color(hex: "0D9488"))
|
||||||
.frame(width: geo.size.width * CGFloat(completedCount) / CGFloat(max(habits.count, 1)))
|
.frame(width: geo.size.width * CGFloat(completedCount) / CGFloat(max(habits.count, 1)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 6)
|
.frame(height: 6).padding(.horizontal).padding(.bottom, 16)
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 40)
|
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40); Spacer()
|
||||||
Spacer()
|
|
||||||
} else if habits.isEmpty {
|
} else if habits.isEmpty {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text("🔥").font(.system(size: 50))
|
Text("🔥").font(.system(size: 50))
|
||||||
Text("Нет привычек").foregroundColor(Color(hex: "8888aa"))
|
Text("Нет привычек").foregroundColor(Color(hex: "8888aa"))
|
||||||
}.padding(.top, 60)
|
}.padding(.top, 60); Spacer()
|
||||||
Spacer()
|
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(habits) { habit in
|
ForEach(habits) { habit in
|
||||||
HabitRowView(habit: habit) { await logHabit(habit) }
|
HabitRowView(habit: habit) { await logHabit(habit) }
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear).listRowSeparator(.hidden)
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain).scrollContentBackground(.hidden)
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,8 +58,7 @@ struct HabitsView: View {
|
|||||||
.refreshable { await loadHabits(refresh: true) }
|
.refreshable { await loadHabits(refresh: true) }
|
||||||
.sheet(isPresented: $showAddHabit) {
|
.sheet(isPresented: $showAddHabit) {
|
||||||
AddHabitView(isPresented: $showAddHabit) { await loadHabits(refresh: true) }
|
AddHabitView(isPresented: $showAddHabit) { await loadHabits(refresh: true) }
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.large]).presentationDragIndicator(.visible)
|
||||||
.presentationDragIndicator(.visible)
|
|
||||||
.presentationBackground(Color(hex: "0a0a1a"))
|
.presentationBackground(Color(hex: "0a0a1a"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,28 +2,32 @@ import SwiftUI
|
|||||||
|
|
||||||
struct MainTabView: View {
|
struct MainTabView: View {
|
||||||
@EnvironmentObject var authManager: AuthManager
|
@EnvironmentObject var authManager: AuthManager
|
||||||
|
@AppStorage("colorScheme") private var colorSchemeRaw: String = "dark"
|
||||||
|
|
||||||
|
var preferredColorScheme: ColorScheme? {
|
||||||
|
colorSchemeRaw == "light" ? .light : .dark
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
TabView {
|
||||||
DashboardView()
|
DashboardView()
|
||||||
.tabItem { Label("Главная", systemImage: "house.fill") }
|
.tabItem { Label("Главная", systemImage: "house.fill") }
|
||||||
|
|
||||||
TasksView()
|
TrackerView()
|
||||||
.tabItem { Label("Задачи", systemImage: "checkmark.circle.fill") }
|
.tabItem { Label("Трекер", systemImage: "chart.bar.fill") }
|
||||||
|
|
||||||
HabitsView()
|
if authManager.userId == 1 {
|
||||||
.tabItem { Label("Привычки", systemImage: "flame.fill") }
|
FinanceView()
|
||||||
|
.tabItem { Label("Финансы", systemImage: "creditcard.fill") }
|
||||||
HealthView()
|
}
|
||||||
.tabItem { Label("Здоровье", systemImage: "heart.fill") }
|
|
||||||
|
|
||||||
SavingsView()
|
SavingsView()
|
||||||
.tabItem { Label("Накопления", systemImage: "chart.bar.fill") }
|
.tabItem { Label("Накопления", systemImage: "building.columns.fill") }
|
||||||
|
|
||||||
ProfileView()
|
SettingsView()
|
||||||
.tabItem { Label("Профиль", systemImage: "person.fill") }
|
.tabItem { Label("Настройки", systemImage: "gearshape.fill") }
|
||||||
}
|
}
|
||||||
.accentColor(Color(hex: "00d4aa"))
|
.accentColor(Color(hex: "0D9488"))
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(preferredColorScheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,13 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// ProfileView is now replaced by SettingsView in MainTabView.
|
||||||
|
// This file is kept for legacy support (ChangePasswordView is here).
|
||||||
|
|
||||||
struct ProfileView: View {
|
struct ProfileView: View {
|
||||||
@EnvironmentObject var authManager: AuthManager
|
@EnvironmentObject var authManager: AuthManager
|
||||||
@State private var showChangePassword = false
|
@State private var showChangePassword = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
SettingsView()
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Header
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
ZStack {
|
|
||||||
Circle().fill(Color(hex: "00d4aa").opacity(0.2)).frame(width: 80, height: 80)
|
|
||||||
Text(String(authManager.userName.prefix(1)).uppercased())
|
|
||||||
.font(.largeTitle.bold()).foregroundColor(Color(hex: "00d4aa"))
|
|
||||||
}
|
|
||||||
Text(authManager.userName).font(.title2.bold()).foregroundColor(.white)
|
|
||||||
}
|
|
||||||
.padding(.top, 40).padding(.bottom, 32)
|
|
||||||
|
|
||||||
// Settings list
|
|
||||||
VStack(spacing: 2) {
|
|
||||||
ProfileRow(icon: "lock.fill", title: "Сменить пароль", color: "7c3aed") {
|
|
||||||
showChangePassword = true
|
|
||||||
}
|
|
||||||
ProfileRow(icon: "heart.fill", title: "Health API ключ", subtitle: authManager.healthApiKey, color: "ff4757") {}
|
|
||||||
Divider().background(Color.white.opacity(0.1)).padding(.vertical, 8)
|
|
||||||
ProfileRow(icon: "rectangle.portrait.and.arrow.right", title: "Выйти", color: "ff4757", isDestructive: true) {
|
|
||||||
authManager.logout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text("Pulse v1.0").font(.caption).foregroundColor(Color(hex: "8888aa")).padding(.bottom, 20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showChangePassword) {
|
|
||||||
ChangePasswordView(isPresented: $showChangePassword)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ProfileRow: View {
|
|
||||||
let icon: String
|
|
||||||
let title: String
|
|
||||||
var subtitle: String? = nil
|
|
||||||
let color: String
|
|
||||||
var isDestructive: Bool = false
|
|
||||||
let action: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: action) {
|
|
||||||
HStack(spacing: 14) {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 8).fill(Color(hex: color).opacity(0.2)).frame(width: 36, height: 36)
|
|
||||||
Image(systemName: icon).foregroundColor(Color(hex: color)).font(.subheadline)
|
|
||||||
}
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(title).foregroundColor(isDestructive ? Color(hex: "ff4757") : .white).font(.callout)
|
|
||||||
if let sub = subtitle {
|
|
||||||
Text(sub).font(.caption).foregroundColor(Color(hex: "8888aa"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
if !isDestructive {
|
|
||||||
Image(systemName: "chevron.right").foregroundColor(Color(hex: "8888aa")).font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(14)
|
|
||||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,17 +22,19 @@ struct ChangePasswordView: View {
|
|||||||
@State private var success = false
|
@State private var success = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
ZStack {
|
||||||
ZStack {
|
Color(hex: "0a0a1a").ignoresSafeArea()
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
VStack(spacing: 16) {
|
||||||
VStack(spacing: 16) {
|
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
||||||
if success {
|
Text("Смена пароля").font(.title3.bold()).foregroundColor(.white).padding(.top, 4)
|
||||||
VStack(spacing: 12) {
|
if success {
|
||||||
Text("✅").font(.system(size: 50))
|
VStack(spacing: 12) {
|
||||||
Text("Пароль изменён!").font(.title2.bold()).foregroundColor(.white)
|
Text("✅").font(.system(size: 50))
|
||||||
}.padding(.top, 40)
|
Text("Пароль изменён!").font(.title2.bold()).foregroundColor(.white)
|
||||||
Button("Закрыть") { isPresented = false }.foregroundColor(Color(hex: "00d4aa"))
|
}.padding(.top, 40)
|
||||||
} else {
|
Button("Закрыть") { isPresented = false }.foregroundColor(Color(hex: "0D9488"))
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 12) {
|
||||||
SecureField("Текущий пароль", text: $oldPassword)
|
SecureField("Текущий пароль", text: $oldPassword)
|
||||||
.padding().background(Color.white.opacity(0.08)).cornerRadius(12).foregroundColor(.white)
|
.padding().background(Color.white.opacity(0.08)).cornerRadius(12).foregroundColor(.white)
|
||||||
SecureField("Новый пароль", text: $newPassword)
|
SecureField("Новый пароль", text: $newPassword)
|
||||||
@@ -111,16 +49,14 @@ struct ChangePasswordView: View {
|
|||||||
else { Text("Сменить").font(.headline).foregroundColor(.black) }
|
else { Text("Сменить").font(.headline).foregroundColor(.black) }
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity).padding()
|
.frame(maxWidth: .infinity).padding()
|
||||||
.background(Color(hex: "00d4aa")).cornerRadius(12)
|
.background(Color(hex: "0D9488")).cornerRadius(12)
|
||||||
.disabled(oldPassword.isEmpty || newPassword.isEmpty || isLoading)
|
.disabled(oldPassword.isEmpty || newPassword.isEmpty || isLoading)
|
||||||
}
|
}
|
||||||
Spacer()
|
Button("Отмена") { isPresented = false }
|
||||||
}.padding()
|
.foregroundColor(Color(hex: "8888aa")).padding(.top, 4)
|
||||||
}
|
}
|
||||||
.navigationTitle("Смена пароля")
|
Spacer()
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
}.padding()
|
||||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Отмена") { isPresented = false } } }
|
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,11 +72,8 @@ struct ChangePasswordView: View {
|
|||||||
req.httpBody = try? JSONEncoder().encode(["old_password": oldPassword, "new_password": newPassword])
|
req.httpBody = try? JSONEncoder().encode(["old_password": oldPassword, "new_password": newPassword])
|
||||||
let (_, response) = (try? await URLSession.shared.data(for: req)) ?? (Data(), nil)
|
let (_, response) = (try? await URLSession.shared.data(for: req)) ?? (Data(), nil)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if (response as? HTTPURLResponse)?.statusCode == 200 {
|
if (response as? HTTPURLResponse)?.statusCode == 200 { success = true }
|
||||||
success = true
|
else { errorMessage = "Неверный текущий пароль" }
|
||||||
} else {
|
|
||||||
errorMessage = "Неверный текущий пароль"
|
|
||||||
}
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,160 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - SavingsView
|
||||||
|
|
||||||
struct SavingsView: View {
|
struct SavingsView: View {
|
||||||
@EnvironmentObject var authManager: AuthManager
|
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "0a0a1a").ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Header
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("Накопления").font(.title.bold()).foregroundColor(.white)
|
Text("Накопления").font(.title.bold()).foregroundColor(.white)
|
||||||
Spacer()
|
Spacer()
|
||||||
}.padding()
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
// Segment control
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
|
||||||
Picker("", selection: $selectedTab) {
|
Picker("", selection: $selectedTab) {
|
||||||
Text("Обзор").tag(0)
|
Text("Обзор").tag(0)
|
||||||
Text("Транзакции").tag(1)
|
Text("Категории").tag(1)
|
||||||
|
Text("Операции").tag(2)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
|
|
||||||
if selectedTab == 0 {
|
switch selectedTab {
|
||||||
SavingsOverviewTab()
|
case 0: SavingsOverviewTab2()
|
||||||
} else {
|
case 1: SavingsCategoriesTab()
|
||||||
SavingsTransactionsTab()
|
default: SavingsOperationsTab()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SavingsOverviewTab: View {
|
// MARK: - SavingsOverviewTab2
|
||||||
|
|
||||||
|
struct SavingsOverviewTab2: View {
|
||||||
@EnvironmentObject var authManager: AuthManager
|
@EnvironmentObject var authManager: AuthManager
|
||||||
@State private var categories: [SavingsCategory] = []
|
@State private var categories: [SavingsCategory] = []
|
||||||
@State private var stats: SavingsStats?
|
@State private var stats: SavingsStats?
|
||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
|
|
||||||
|
var recurringCategories: [SavingsCategory] { categories.filter { $0.isRecurring == true } }
|
||||||
|
var hasOverdue: Bool { (stats?.overdueCount ?? 0) > 0 }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
ScrollView {
|
||||||
if isLoading {
|
VStack(spacing: 16) {
|
||||||
ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 40)
|
if isLoading {
|
||||||
Spacer()
|
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
// Total Balance Card
|
||||||
VStack(spacing: 16) {
|
if let s = stats {
|
||||||
if let s = stats { SavingsTotalCard(stats: s) }
|
VStack(spacing: 16) {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 6) {
|
||||||
ForEach(categories) { cat in SavingsCategoryCard(category: cat) }
|
Text("Общий баланс").font(.subheadline).foregroundColor(Color(hex: "8888aa"))
|
||||||
}.padding(.horizontal)
|
Text(formatAmt(s.totalBalance ?? 0))
|
||||||
|
.font(.system(size: 36, weight: .bold)).foregroundColor(.white)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Пополнения").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
Text("+\(formatAmt(s.totalDeposits ?? 0))")
|
||||||
|
.font(.callout.bold()).foregroundColor(Color(hex: "0D9488"))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Снятия").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
Text("-\(formatAmt(s.totalWithdrawals ?? 0))")
|
||||||
|
.font(.callout.bold()).foregroundColor(Color(hex: "ff4757"))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Категорий").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
Text("\(s.categoriesCount ?? 0)")
|
||||||
|
.font(.callout.bold()).foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background(LinearGradient(colors: [Color(hex: "1a1a3e"), Color(hex: "12122a")], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
|
.cornerRadius(20)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "0D9488").opacity(0.3), lineWidth: 1))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overdue block
|
||||||
|
if hasOverdue, let s = stats {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(Color(hex: "ff4757"))
|
||||||
|
.font(.title3)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Просроченные платежи").font(.callout.bold()).foregroundColor(Color(hex: "ff4757"))
|
||||||
|
Text("\(s.overdueCount ?? 0) платежей на сумму \(formatAmt(s.overdueAmount ?? 0))")
|
||||||
|
.font(.caption).foregroundColor(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 14).fill(Color(hex: "ff4757").opacity(0.12)))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "ff4757").opacity(0.3), lineWidth: 1))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories progress
|
||||||
|
if !categories.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Прогресс по категориям")
|
||||||
|
.font(.subheadline.bold()).foregroundColor(.white).padding(.horizontal)
|
||||||
|
ForEach(categories.filter { $0.isClosed != true }) { cat in
|
||||||
|
SavingsCategoryCard(category: cat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monthly payments
|
||||||
|
if !recurringCategories.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Ежемесячные платежи")
|
||||||
|
.font(.subheadline.bold()).foregroundColor(.white).padding(.horizontal)
|
||||||
|
ForEach(recurringCategories) { cat in
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(Color(hex: cat.colorHex).opacity(0.15)).frame(width: 40, height: 40)
|
||||||
|
Image(systemName: cat.icon).foregroundColor(Color(hex: cat.colorHex)).font(.body)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(cat.name).font(.callout).foregroundColor(.white)
|
||||||
|
if let day = cat.recurringDay {
|
||||||
|
Text("\(day) числа каждого месяца").font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(formatAmt(cat.recurringAmount ?? 0))
|
||||||
|
.font(.callout.bold()).foregroundColor(Color(hex: cat.colorHex))
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.04)))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(minLength: 80)
|
||||||
}
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.task { await loadData() }
|
.task { await load() }
|
||||||
.refreshable { await loadData(refresh: true) }
|
.refreshable { await load(refresh: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadData(refresh: Bool = false) async {
|
func load(refresh: Bool = false) async {
|
||||||
if !refresh { isLoading = true }
|
if !refresh { isLoading = true }
|
||||||
async let cats = APIService.shared.getSavingsCategories(token: authManager.token)
|
async let cats = APIService.shared.getSavingsCategories(token: authManager.token)
|
||||||
async let st = APIService.shared.getSavingsStats(token: authManager.token)
|
async let st = APIService.shared.getSavingsStats(token: authManager.token)
|
||||||
@@ -67,228 +162,466 @@ struct SavingsOverviewTab: View {
|
|||||||
stats = try? await st
|
stats = try? await st
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatAmt(_ v: Double) -> String {
|
||||||
|
v >= 1_000_000 ? String(format: "%.2f млн ₽", v / 1_000_000) : String(format: "%.0f ₽", v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SavingsTransactionsTab: View {
|
// MARK: - SavingsCategoriesTab
|
||||||
|
|
||||||
|
struct SavingsCategoriesTab: View {
|
||||||
@EnvironmentObject var authManager: AuthManager
|
@EnvironmentObject var authManager: AuthManager
|
||||||
@State private var transactions: [SavingsTransaction] = []
|
@State private var categories: [SavingsCategory] = []
|
||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
|
@State private var showAdd = false
|
||||||
|
@State private var editingCategory: SavingsCategory?
|
||||||
|
|
||||||
|
var active: [SavingsCategory] { categories.filter { $0.isClosed != true } }
|
||||||
|
var closed: [SavingsCategory] { categories.filter { $0.isClosed == true } }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
if isLoading {
|
Group {
|
||||||
ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 40)
|
if isLoading {
|
||||||
Spacer()
|
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
|
||||||
} else if transactions.isEmpty {
|
Spacer()
|
||||||
VStack(spacing: 12) {
|
} else if active.isEmpty {
|
||||||
Text("💸").font(.system(size: 50))
|
VStack { EmptyState(icon: "building.columns", text: "Нет категорий"); Spacer() }
|
||||||
Text("Нет транзакций").foregroundColor(Color(hex: "8888aa"))
|
} else {
|
||||||
}.padding(.top, 60)
|
List {
|
||||||
Spacer()
|
Section(header: Text("Активные").foregroundColor(Color(hex: "8888aa"))) {
|
||||||
} else {
|
ForEach(active) { cat in
|
||||||
List {
|
SavingsCategoryRow(category: cat)
|
||||||
ForEach(transactions) { tx in
|
.listRowBackground(Color.clear)
|
||||||
SavingsTransactionRow(transaction: tx)
|
.listRowSeparator(.hidden)
|
||||||
.listRowBackground(Color.clear)
|
.onTapGesture { editingCategory = cat }
|
||||||
.listRowSeparator(.hidden)
|
}
|
||||||
|
.onDelete { idx in
|
||||||
|
let toDelete = idx.map { active[$0] }
|
||||||
|
Task {
|
||||||
|
for c in toDelete { try? await APIService.shared.deleteSavingsCategory(token: authManager.token, id: c.id) }
|
||||||
|
await load(refresh: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !closed.isEmpty {
|
||||||
|
Section(header: Text("Закрытые").foregroundColor(Color(hex: "8888aa"))) {
|
||||||
|
ForEach(closed) { cat in
|
||||||
|
SavingsCategoryRow(category: cat)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
}
|
}
|
||||||
|
.refreshable { await load(refresh: true) }
|
||||||
|
|
||||||
|
Button(action: { showAdd = 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 load() }
|
||||||
|
.sheet(isPresented: $showAdd) {
|
||||||
|
AddSavingsCategoryView(isPresented: $showAdd) { await load(refresh: true) }
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationBackground(Color(hex: "0a0a1a"))
|
||||||
}
|
}
|
||||||
.task { await loadData() }
|
|
||||||
.refreshable { await loadData(refresh: true) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadData(refresh: Bool = false) async {
|
func load(refresh: Bool = false) async {
|
||||||
if !refresh { isLoading = true }
|
if !refresh { isLoading = true }
|
||||||
transactions = (try? await APIService.shared.getSavingsTransactions(token: authManager.token)) ?? []
|
categories = (try? await APIService.shared.getSavingsCategories(token: authManager.token)) ?? []
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SavingsTransactionRow: View {
|
// MARK: - SavingsCategoryRow
|
||||||
|
|
||||||
|
struct SavingsCategoryRow: View {
|
||||||
|
let category: SavingsCategory
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(Color(hex: category.colorHex).opacity(0.15)).frame(width: 44, height: 44)
|
||||||
|
Image(systemName: category.icon).foregroundColor(Color(hex: category.colorHex)).font(.title3)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(category.typeEmoji)
|
||||||
|
Text(category.name).font(.callout.bold()).foregroundColor(.white)
|
||||||
|
}
|
||||||
|
Text(category.typeLabel).font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(formatAmt(category.currentAmount ?? 0))
|
||||||
|
.font(.callout.bold()).foregroundColor(Color(hex: category.colorHex))
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.04)))
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
func formatAmt(_ v: Double) -> String {
|
||||||
|
v >= 1_000_000 ? String(format: "%.2f млн ₽", v / 1_000_000) : String(format: "%.0f ₽", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AddSavingsCategoryView
|
||||||
|
|
||||||
|
struct AddSavingsCategoryView: View {
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
@EnvironmentObject var authManager: AuthManager
|
||||||
|
let onAdded: () async -> Void
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var isDeposit = false
|
||||||
|
@State private var isRecurring = false
|
||||||
|
@State private var isAccount = false
|
||||||
|
@State private var recurringAmount = ""
|
||||||
|
@State private var interestRate = ""
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color(hex: "0a0a1a").ignoresSafeArea()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
||||||
|
HStack {
|
||||||
|
Button("Отмена") { isPresented = false }.foregroundColor(Color(hex: "8888aa"))
|
||||||
|
Spacer()
|
||||||
|
Text("Новая категория").font(.headline).foregroundColor(.white)
|
||||||
|
Spacer()
|
||||||
|
Button(action: save) {
|
||||||
|
if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) }
|
||||||
|
else { Text("Добавить").foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) }
|
||||||
|
}.disabled(name.isEmpty || isLoading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20).padding(.vertical, 16)
|
||||||
|
Divider().background(Color.white.opacity(0.1))
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
fieldLabel("Название") {
|
||||||
|
TextField("Например: На машину", text: $name)
|
||||||
|
.foregroundColor(.white).padding(14)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label("Тип", systemImage: "tag.fill").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TypeButton(label: "💰 Накопление", selected: !isDeposit && !isRecurring && !isAccount) { isDeposit = false; isRecurring = false; isAccount = false }
|
||||||
|
TypeButton(label: "🏦 Вклад", selected: isDeposit) { isDeposit = true; isRecurring = false; isAccount = false }
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TypeButton(label: "🔄 Регулярные", selected: isRecurring) { isDeposit = false; isRecurring = true; isAccount = false }
|
||||||
|
TypeButton(label: "🏧 Счёт", selected: isAccount) { isDeposit = false; isRecurring = false; isAccount = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isRecurring {
|
||||||
|
fieldLabel("Сумма / мес. (₽)") {
|
||||||
|
TextField("0", text: $recurringAmount).keyboardType(.decimalPad)
|
||||||
|
.foregroundColor(.white).padding(14)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isDeposit {
|
||||||
|
fieldLabel("Ставка (%)") {
|
||||||
|
TextField("0.0", text: $interestRate).keyboardType(.decimalPad)
|
||||||
|
.foregroundColor(.white).padding(14)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder func fieldLabel<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label(label, systemImage: "pencil").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() {
|
||||||
|
isLoading = true
|
||||||
|
Task {
|
||||||
|
var params: [String: Any] = ["name": name, "is_deposit": isDeposit, "is_recurring": isRecurring, "is_account": isAccount]
|
||||||
|
if isRecurring, let a = Double(recurringAmount) { params["recurring_amount"] = a }
|
||||||
|
if isDeposit, let r = Double(interestRate) { params["interest_rate"] = r }
|
||||||
|
if let body = try? JSONSerialization.data(withJSONObject: params) {
|
||||||
|
try? await APIService.shared.createSavingsCategory(token: authManager.token, body: body)
|
||||||
|
}
|
||||||
|
await onAdded()
|
||||||
|
await MainActor.run { isPresented = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TypeButton: View {
|
||||||
|
let label: String
|
||||||
|
let selected: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(label).font(.caption.bold())
|
||||||
|
.foregroundColor(selected ? .black : .white)
|
||||||
|
.frame(maxWidth: .infinity).padding(.vertical, 10)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 10).fill(selected ? Color(hex: "0D9488") : Color.white.opacity(0.07)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SavingsOperationsTab
|
||||||
|
|
||||||
|
struct SavingsOperationsTab: View {
|
||||||
|
@EnvironmentObject var authManager: AuthManager
|
||||||
|
@State private var transactions: [SavingsTransaction] = []
|
||||||
|
@State private var categories: [SavingsCategory] = []
|
||||||
|
@State private var selectedCategoryId: Int? = nil
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var showAdd = false
|
||||||
|
|
||||||
|
var filtered: [SavingsTransaction] {
|
||||||
|
guard let cid = selectedCategoryId else { return transactions }
|
||||||
|
return transactions.filter { $0.categoryId == cid }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottomTrailing) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Filter pills
|
||||||
|
if !categories.isEmpty {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
FilterPill(label: "Все", selected: selectedCategoryId == nil) { selectedCategoryId = nil }
|
||||||
|
ForEach(categories.filter { $0.isClosed != true }) { cat in
|
||||||
|
FilterPill(label: cat.name, selected: selectedCategoryId == cat.id) { selectedCategoryId = cat.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLoading {
|
||||||
|
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
|
||||||
|
Spacer()
|
||||||
|
} else if filtered.isEmpty {
|
||||||
|
VStack { EmptyState(icon: "arrow.left.arrow.right", text: "Нет операций"); Spacer() }
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(filtered) { tx in
|
||||||
|
SavingsTransactionRow2(transaction: tx)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
.onDelete { idx in
|
||||||
|
let toDelete = idx.map { filtered[$0] }
|
||||||
|
Task {
|
||||||
|
for t in toDelete { try? await APIService.shared.deleteSavingsTransaction(token: authManager.token, id: t.id) }
|
||||||
|
await load(refresh: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { await load(refresh: true) }
|
||||||
|
|
||||||
|
Button(action: { showAdd = 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 load() }
|
||||||
|
.sheet(isPresented: $showAdd) {
|
||||||
|
AddSavingsTransactionView(isPresented: $showAdd, categories: categories) { await load(refresh: true) }
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationBackground(Color(hex: "0a0a1a"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(refresh: Bool = false) async {
|
||||||
|
if !refresh { isLoading = true }
|
||||||
|
async let txs = APIService.shared.getSavingsTransactions(token: authManager.token, categoryId: selectedCategoryId)
|
||||||
|
async let cats = APIService.shared.getSavingsCategories(token: authManager.token)
|
||||||
|
transactions = (try? await txs) ?? []
|
||||||
|
categories = (try? await cats) ?? []
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FilterPill: View {
|
||||||
|
let label: String
|
||||||
|
let selected: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(label).font(.caption.bold())
|
||||||
|
.foregroundColor(selected ? .black : .white)
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 8)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 20).fill(selected ? Color(hex: "0D9488") : Color.white.opacity(0.08)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SavingsTransactionRow2: View {
|
||||||
let transaction: SavingsTransaction
|
let transaction: SavingsTransaction
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.fill((transaction.isDeposit ? Color(hex: "00d4aa") : Color(hex: "ff4757")).opacity(0.15))
|
.fill((transaction.isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757")).opacity(0.15))
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40, height: 40)
|
||||||
Image(systemName: transaction.isDeposit ? "arrow.down.circle.fill" : "arrow.up.circle.fill")
|
Image(systemName: transaction.isDeposit ? "arrow.down.circle.fill" : "arrow.up.circle.fill")
|
||||||
.foregroundColor(transaction.isDeposit ? Color(hex: "00d4aa") : Color(hex: "ff4757"))
|
.foregroundColor(transaction.isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757"))
|
||||||
}
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
Text(transaction.categoryName ?? "Без категории").font(.callout).foregroundColor(.white)
|
||||||
Text(transaction.categoryName ?? "Без категории")
|
|
||||||
.font(.callout).foregroundColor(.white)
|
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
if let userName = transaction.userName {
|
if let name = transaction.userName { Text(name).font(.caption).foregroundColor(Color(hex: "8888aa")) }
|
||||||
Text(userName).font(.caption).foregroundColor(Color(hex: "8888aa"))
|
Text(transaction.dateFormatted).font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
||||||
}
|
}
|
||||||
if let date = transaction.date {
|
if let desc = transaction.description, !desc.isEmpty {
|
||||||
Text(formatDate(date)).font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
Text(desc).font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Text("\(transaction.isDeposit ? "+" : "-")\(formatAmt(transaction.amount))")
|
||||||
Text("\(transaction.isDeposit ? "+" : "-")\(formatAmount(transaction.amount))")
|
|
||||||
.font(.callout.bold())
|
.font(.callout.bold())
|
||||||
.foregroundColor(transaction.isDeposit ? Color(hex: "00d4aa") : Color(hex: "ff4757"))
|
.foregroundColor(transaction.isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757"))
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.04)))
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.04)))
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
|
func formatAmt(_ v: Double) -> String { v >= 1_000_000 ? String(format: "%.2f млн ₽", v / 1_000_000) : String(format: "%.0f ₽", v) }
|
||||||
func formatAmount(_ v: Double) -> String {
|
|
||||||
if v >= 1_000_000 { return String(format: "%.1f млн ₽", v / 1_000_000) }
|
|
||||||
return String(format: "%.0f ₽", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDate(_ s: String) -> String {
|
|
||||||
let parts = s.prefix(10).split(separator: "-")
|
|
||||||
guard parts.count == 3 else { return String(s.prefix(10)) }
|
|
||||||
return "\(parts[2]).\(parts[1]).\(parts[0])"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SavingsTotalCard
|
// MARK: - AddSavingsTransactionView
|
||||||
|
|
||||||
|
struct AddSavingsTransactionView: View {
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
@EnvironmentObject var authManager: AuthManager
|
||||||
|
let categories: [SavingsCategory]
|
||||||
|
let onAdded: () async -> Void
|
||||||
|
|
||||||
|
@State private var amount = ""
|
||||||
|
@State private var description = ""
|
||||||
|
@State private var type = "deposit"
|
||||||
|
@State private var selectedCategoryId: Int? = nil
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
var isDeposit: Bool { type == "deposit" }
|
||||||
|
|
||||||
struct SavingsTotalCard: View {
|
|
||||||
let stats: SavingsStats
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
ZStack {
|
||||||
VStack(spacing: 6) {
|
Color(hex: "0a0a1a").ignoresSafeArea()
|
||||||
Text("Общий баланс").font(.subheadline).foregroundColor(Color(hex: "8888aa"))
|
VStack(spacing: 0) {
|
||||||
Text(formatAmount(stats.totalBalance ?? 0))
|
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
||||||
.font(.system(size: 36, weight: .bold))
|
HStack {
|
||||||
.foregroundColor(.white)
|
Button("Отмена") { isPresented = false }.foregroundColor(Color(hex: "8888aa"))
|
||||||
}
|
Spacer()
|
||||||
|
Text("Новая операция").font(.headline).foregroundColor(.white)
|
||||||
HStack(spacing: 0) {
|
Spacer()
|
||||||
VStack(spacing: 4) {
|
Button(action: save) {
|
||||||
Text("Пополнения").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) }
|
||||||
Text("+\(formatAmount(stats.totalDeposits ?? 0))").font(.callout.bold()).foregroundColor(Color(hex: "00d4aa"))
|
else { Text("Добавить").foregroundColor((amount.isEmpty || selectedCategoryId == nil) ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) }
|
||||||
|
}.disabled(amount.isEmpty || selectedCategoryId == nil || isLoading)
|
||||||
}
|
}
|
||||||
Spacer()
|
.padding(.horizontal, 20).padding(.vertical, 16)
|
||||||
VStack(spacing: 4) {
|
Divider().background(Color.white.opacity(0.1))
|
||||||
Text("Снятия").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
ScrollView {
|
||||||
Text("-\(formatAmount(stats.totalWithdrawals ?? 0))").font(.callout.bold()).foregroundColor(Color(hex: "ff4757"))
|
VStack(spacing: 20) {
|
||||||
}
|
// Type toggle
|
||||||
Spacer()
|
HStack(spacing: 0) {
|
||||||
VStack(spacing: 4) {
|
Button(action: { type = "deposit" }) {
|
||||||
Text("Категорий").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
Text("Пополнение ↓")
|
||||||
Text("\(stats.categoriesCount ?? 0)").font(.callout.bold()).foregroundColor(.white)
|
.font(.callout.bold()).foregroundColor(isDeposit ? .black : Color(hex: "0D9488"))
|
||||||
}
|
.frame(maxWidth: .infinity).padding(.vertical, 12)
|
||||||
}
|
.background(isDeposit ? Color(hex: "0D9488") : Color.clear)
|
||||||
}
|
}
|
||||||
.padding(20)
|
Button(action: { type = "withdrawal" }) {
|
||||||
.background(
|
Text("Снятие ↑")
|
||||||
LinearGradient(colors: [Color(hex: "1a1a3e"), Color(hex: "12122a")], startPoint: .topLeading, endPoint: .bottomTrailing)
|
.font(.callout.bold()).foregroundColor(!isDeposit ? .black : Color(hex: "ff4757"))
|
||||||
)
|
.frame(maxWidth: .infinity).padding(.vertical, 12)
|
||||||
.cornerRadius(20)
|
.background(!isDeposit ? Color(hex: "ff4757") : Color.clear)
|
||||||
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "00d4aa").opacity(0.3), lineWidth: 1))
|
}
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatAmount(_ v: Double) -> String {
|
|
||||||
if v >= 1_000_000 {
|
|
||||||
return String(format: "%.2f млн ₽", v / 1_000_000)
|
|
||||||
} else if v >= 1000 {
|
|
||||||
return String(format: "%.0f ₽", v)
|
|
||||||
}
|
|
||||||
return String(format: "%.0f ₽", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - SavingsCategoryCard
|
|
||||||
|
|
||||||
struct SavingsCategoryCard: View {
|
|
||||||
let category: SavingsCategory
|
|
||||||
@State private var appeared = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.fill(Color(hex: category.color).opacity(0.2))
|
|
||||||
.frame(width: 46, height: 46)
|
|
||||||
Image(systemName: category.icon)
|
|
||||||
.foregroundColor(Color(hex: category.color))
|
|
||||||
.font(.title3)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(category.name).font(.callout.bold()).foregroundColor(.white)
|
|
||||||
Text(category.typeLabel).font(.caption).foregroundColor(Color(hex: "8888aa"))
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
|
||||||
Text(formatAmount(category.currentAmount ?? 0))
|
|
||||||
.font(.callout.bold())
|
|
||||||
.foregroundColor(Color(hex: category.color))
|
|
||||||
if let end = category.depositEndDate {
|
|
||||||
Text("до \(formatDate(end))")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(Color(hex: "8888aa"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if category.isDeposit == true, let target = category.depositAmount, target > 0 {
|
|
||||||
let progress = min((category.currentAmount ?? 0) / target, 1.0)
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
GeometryReader { geo in
|
|
||||||
ZStack(alignment: .leading) {
|
|
||||||
RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.08))
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.fill(LinearGradient(colors: [Color(hex: "ffa502"), Color(hex: "ff6b35")], startPoint: .leading, endPoint: .trailing))
|
|
||||||
.frame(width: geo.size.width * CGFloat(appeared ? progress : 0))
|
|
||||||
.animation(.easeInOut(duration: 0.8), value: appeared)
|
|
||||||
}
|
}
|
||||||
}
|
.background(Color.white.opacity(0.07)).cornerRadius(12)
|
||||||
.frame(height: 6)
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("\(Int(progress * 100))%")
|
Text(isDeposit ? "+" : "−").font(.title.bold()).foregroundColor(isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757"))
|
||||||
.font(.caption2).foregroundColor(Color(hex: "ffa502"))
|
TextField("0", text: $amount).keyboardType(.decimalPad)
|
||||||
Spacer()
|
.font(.system(size: 32, weight: .bold)).foregroundColor(.white).multilineTextAlignment(.center)
|
||||||
Text("цель: \(formatAmount(target))")
|
Text("₽").font(.title.bold()).foregroundColor(Color(hex: "8888aa"))
|
||||||
.font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
}
|
||||||
}
|
.padding(20)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.07)))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label("Категория", systemImage: "tag.fill").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
ForEach(categories.filter { $0.isClosed != true }) { cat in
|
||||||
|
Button(action: { selectedCategoryId = selectedCategoryId == cat.id ? nil : cat.id }) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: cat.icon).foregroundColor(Color(hex: cat.colorHex)).font(.body)
|
||||||
|
Text(cat.name).font(.callout).foregroundColor(.white)
|
||||||
|
Spacer()
|
||||||
|
if selectedCategoryId == cat.id {
|
||||||
|
Image(systemName: "checkmark").foregroundColor(Color(hex: "0D9488"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 12).fill(selectedCategoryId == cat.id ? Color(hex: "0D9488").opacity(0.15) : Color.white.opacity(0.05)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label("Описание", systemImage: "text.alignleft").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
TextField("Комментарий...", text: $description)
|
||||||
|
.foregroundColor(.white).padding(14)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
||||||
|
}
|
||||||
|
}.padding(20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
}
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 16)
|
func save() {
|
||||||
.fill(Color.white.opacity(0.04))
|
guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")),
|
||||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color(hex: category.color).opacity(0.15), lineWidth: 1))
|
let cid = selectedCategoryId else { return }
|
||||||
)
|
isLoading = true
|
||||||
.opacity(appeared ? 1 : 0)
|
Task {
|
||||||
.offset(y: appeared ? 0 : 20)
|
let req = CreateSavingsTransactionRequest(categoryId: cid, amount: a, type: type, description: description.isEmpty ? nil : description)
|
||||||
.onAppear {
|
try? await APIService.shared.createSavingsTransaction(token: authManager.token, request: req)
|
||||||
withAnimation(.easeOut(duration: 0.4)) { appeared = true }
|
await onAdded()
|
||||||
|
await MainActor.run { isPresented = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatAmount(_ v: Double) -> String {
|
|
||||||
if v >= 1_000_000 { return String(format: "%.2f млн ₽", v / 1_000_000) }
|
|
||||||
return String(format: "%.0f ₽", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDate(_ s: String) -> String {
|
|
||||||
let parts = s.prefix(10).split(separator: "-")
|
|
||||||
guard parts.count == 3 else { return s }
|
|
||||||
return "\(parts[2]).\(parts[1]).\(parts[0])"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
293
PulseHealth/Views/Settings/SettingsView.swift
Normal file
293
PulseHealth/Views/Settings/SettingsView.swift
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - SettingsView
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@EnvironmentObject var authManager: AuthManager
|
||||||
|
@AppStorage("colorScheme") private var colorSchemeRaw: String = "dark"
|
||||||
|
@State private var profile: UserProfile?
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var showPasswordChange = false
|
||||||
|
|
||||||
|
// Profile fields
|
||||||
|
@State private var telegramChatId = ""
|
||||||
|
@State private var morningNotification = true
|
||||||
|
@State private var eveningNotification = true
|
||||||
|
@State private var morningTime = "09:00"
|
||||||
|
@State private var eveningTime = "21:00"
|
||||||
|
@State private var timezone = "Europe/Moscow"
|
||||||
|
@State private var username = ""
|
||||||
|
|
||||||
|
var isDark: Bool { colorSchemeRaw != "light" }
|
||||||
|
|
||||||
|
let timezones = [
|
||||||
|
"Europe/Moscow", "Europe/Kaliningrad", "Europe/Samara", "Asia/Yekaterinburg",
|
||||||
|
"Asia/Omsk", "Asia/Krasnoyarsk", "Asia/Irkutsk", "Asia/Yakutsk",
|
||||||
|
"Asia/Vladivostok", "Asia/Magadan", "Asia/Kamchatka",
|
||||||
|
"UTC", "Europe/London", "Europe/Berlin", "Europe/Paris", "America/New_York"
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color(hex: "0a0a1a").ignoresSafeArea()
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header / Avatar
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(Color(hex: "0D9488").opacity(0.2)).frame(width: 80, height: 80)
|
||||||
|
Text(String(authManager.userName.prefix(1)).uppercased())
|
||||||
|
.font(.largeTitle.bold()).foregroundColor(Color(hex: "0D9488"))
|
||||||
|
}
|
||||||
|
Text(authManager.userName).font(.title2.bold()).foregroundColor(.white)
|
||||||
|
Text("ID: \(authManager.userId)").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
}
|
||||||
|
.padding(.top, 24)
|
||||||
|
.padding(.bottom, 28)
|
||||||
|
|
||||||
|
if isLoading {
|
||||||
|
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 20)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// MARK: Appearance
|
||||||
|
SettingsSection(title: "Внешний вид") {
|
||||||
|
SettingsToggle(icon: "moon.fill", title: isDark ? "Тёмная тема" : "Светлая тема", color: "6366f1", isOn: isDark) {
|
||||||
|
colorSchemeRaw = isDark ? "light" : "dark"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Profile
|
||||||
|
SettingsSection(title: "Профиль") {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Label("Имя пользователя", systemImage: "person.fill")
|
||||||
|
.font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
TextField("Username", text: $username)
|
||||||
|
.foregroundColor(.white).padding(14)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
|
||||||
|
SettingsButton(icon: "lock.fill", title: "Сменить пароль", color: "7c3aed") {
|
||||||
|
showPasswordChange = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Telegram
|
||||||
|
SettingsSection(title: "Telegram Бот") {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label("Chat ID", systemImage: "paperplane.fill")
|
||||||
|
.font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
TextField("Например: 123456789", text: $telegramChatId)
|
||||||
|
.keyboardType(.numbersAndPunctuation)
|
||||||
|
.foregroundColor(.white).padding(14)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "info.circle").font(.caption2).foregroundColor(Color(hex: "0D9488"))
|
||||||
|
Text("Напишите /start боту @pulse_tracking_bot, чтобы получить Chat ID")
|
||||||
|
.font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Notifications
|
||||||
|
SettingsSection(title: "Уведомления") {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
SettingsToggle(icon: "sunrise.fill", title: "Утренние уведомления", color: "ffa502", isOn: morningNotification) {
|
||||||
|
morningNotification.toggle()
|
||||||
|
}
|
||||||
|
if morningNotification {
|
||||||
|
HStack {
|
||||||
|
Text("Время").font(.callout).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
Spacer()
|
||||||
|
TextField("09:00", text: $morningTime)
|
||||||
|
.keyboardType(.numbersAndPunctuation)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(width: 60)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider().background(Color.white.opacity(0.08))
|
||||||
|
|
||||||
|
SettingsToggle(icon: "moon.stars.fill", title: "Вечерние уведомления", color: "6366f1", isOn: eveningNotification) {
|
||||||
|
eveningNotification.toggle()
|
||||||
|
}
|
||||||
|
if eveningNotification {
|
||||||
|
HStack {
|
||||||
|
Text("Время").font(.callout).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
Spacer()
|
||||||
|
TextField("21:00", text: $eveningTime)
|
||||||
|
.keyboardType(.numbersAndPunctuation)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(width: 60)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Timezone
|
||||||
|
SettingsSection(title: "Часовой пояс") {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label("Выберите часовой пояс", systemImage: "clock.fill")
|
||||||
|
.font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
Picker("Часовой пояс", selection: $timezone) {
|
||||||
|
ForEach(timezones, id: \.self) { tz in Text(tz).tag(tz) }
|
||||||
|
}
|
||||||
|
.pickerStyle(.wheel)
|
||||||
|
.frame(height: 120)
|
||||||
|
.clipped()
|
||||||
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.04)))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Save Button
|
||||||
|
Button(action: { Task { await saveProfile() } }) {
|
||||||
|
HStack {
|
||||||
|
if isSaving { ProgressView().tint(.black).scaleEffect(0.8) }
|
||||||
|
else { Text("Сохранить изменения").font(.headline).foregroundColor(.black) }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity).padding()
|
||||||
|
.background(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .leading, endPoint: .trailing))
|
||||||
|
.cornerRadius(14)
|
||||||
|
}
|
||||||
|
.disabled(isSaving)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// MARK: Logout
|
||||||
|
Button(action: { authManager.logout() }) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||||
|
Text("Выйти из аккаунта")
|
||||||
|
}
|
||||||
|
.font(.callout.bold())
|
||||||
|
.foregroundColor(Color(hex: "ff4757"))
|
||||||
|
.frame(maxWidth: .infinity).padding()
|
||||||
|
.background(RoundedRectangle(cornerRadius: 14).fill(Color(hex: "ff4757").opacity(0.1)))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "ff4757").opacity(0.3), lineWidth: 1))
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Text("Pulse v1.1 • Made with ❤️").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await loadProfile() }
|
||||||
|
.sheet(isPresented: $showPasswordChange) {
|
||||||
|
ChangePasswordView(isPresented: $showPasswordChange)
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationBackground(Color(hex: "0a0a1a"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadProfile() async {
|
||||||
|
isLoading = true
|
||||||
|
username = authManager.userName
|
||||||
|
if let p = try? await APIService.shared.getProfile(token: authManager.token) {
|
||||||
|
profile = p
|
||||||
|
telegramChatId = p.telegramChatId ?? ""
|
||||||
|
morningNotification = p.morningNotification ?? true
|
||||||
|
eveningNotification = p.eveningNotification ?? true
|
||||||
|
morningTime = p.morningTime ?? "09:00"
|
||||||
|
eveningTime = p.eveningTime ?? "21:00"
|
||||||
|
timezone = p.timezone ?? "Europe/Moscow"
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveProfile() async {
|
||||||
|
isSaving = true
|
||||||
|
let req = UpdateProfileRequest(
|
||||||
|
telegramChatId: telegramChatId.isEmpty ? nil : telegramChatId,
|
||||||
|
morningNotification: morningNotification,
|
||||||
|
eveningNotification: eveningNotification,
|
||||||
|
morningTime: morningTime,
|
||||||
|
eveningTime: eveningTime,
|
||||||
|
timezone: timezone
|
||||||
|
)
|
||||||
|
_ = try? await APIService.shared.updateProfile(token: authManager.token, request: req)
|
||||||
|
// Update username if changed
|
||||||
|
if username != authManager.userName {
|
||||||
|
var req2 = URLRequest(url: URL(string: "https://api.digital-home.site/auth/me")!)
|
||||||
|
req2.httpMethod = "PUT"
|
||||||
|
req2.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req2.setValue("Bearer \(authManager.token)", forHTTPHeaderField: "Authorization")
|
||||||
|
req2.httpBody = try? JSONEncoder().encode(["username": username])
|
||||||
|
_ = try? await URLSession.shared.data(for: req2)
|
||||||
|
await MainActor.run { authManager.userName = username; UserDefaults.standard.set(username, forKey: "userName") }
|
||||||
|
}
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SettingsSection
|
||||||
|
|
||||||
|
struct SettingsSection<Content: View>: View {
|
||||||
|
let title: String
|
||||||
|
@ViewBuilder let content: () -> Content
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(title).font(.subheadline.bold()).foregroundColor(Color(hex: "8888aa")).padding(.horizontal)
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SettingsToggle
|
||||||
|
|
||||||
|
struct SettingsToggle: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let color: String
|
||||||
|
let isOn: Bool
|
||||||
|
let onToggle: () -> Void
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 8).fill(Color(hex: color).opacity(0.2)).frame(width: 36, height: 36)
|
||||||
|
Image(systemName: icon).foregroundColor(Color(hex: color)).font(.subheadline)
|
||||||
|
}
|
||||||
|
Text(title).font(.callout).foregroundColor(.white)
|
||||||
|
Spacer()
|
||||||
|
Toggle("", isOn: Binding(get: { isOn }, set: { _ in onToggle() }))
|
||||||
|
.tint(Color(hex: "0D9488"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SettingsButton
|
||||||
|
|
||||||
|
struct SettingsButton: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let color: String
|
||||||
|
let action: () -> Void
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 8).fill(Color(hex: color).opacity(0.2)).frame(width: 36, height: 36)
|
||||||
|
Image(systemName: icon).foregroundColor(Color(hex: color)).font(.subheadline)
|
||||||
|
}
|
||||||
|
Text(title).font(.callout).foregroundColor(.white)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right").foregroundColor(Color(hex: "8888aa")).font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,103 +4,136 @@ struct AddTaskView: View {
|
|||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@EnvironmentObject var authManager: AuthManager
|
@EnvironmentObject var authManager: AuthManager
|
||||||
let onAdded: () async -> Void
|
let onAdded: () async -> Void
|
||||||
|
|
||||||
@State private var title = ""
|
@State private var title = ""
|
||||||
@State private var description = ""
|
@State private var description = ""
|
||||||
@State private var priority: Int = 2
|
@State private var priority: Int = 2
|
||||||
|
@State private var selectedIcon = "✅"
|
||||||
|
@State private var selectedColor = "#0D9488"
|
||||||
|
@State private var hasDueDate = false
|
||||||
|
@State private var dueDate = Date()
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
|
|
||||||
let priorities: [(Int, String, String)] = [
|
let priorities: [(Int, String, String)] = [
|
||||||
(1, "Низкий", "8888aa"),
|
(1, "Низкий", "8888aa"),
|
||||||
(2, "Средний", "ffa502"),
|
(2, "Средний", "ffa502"),
|
||||||
(3, "Высокий", "ff4757"),
|
(3, "Высокий", "ff4757"),
|
||||||
(4, "Срочный", "ff0000")
|
(4, "Срочный", "ff0000")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
let icons = ["✅","📌","🎯","💼","🏠","🛒","📞","🎓","💊","🚗",
|
||||||
|
"📅","⚡","🔧","📬","💡","🏋️","🌿","🎵","✍️","🌏"]
|
||||||
|
|
||||||
|
let colors = ["#0D9488","#7c3aed","#ff4757","#ffa502","#6366f1",
|
||||||
|
"#ec4899","#14b8a6","#f59e0b","#10b981","#3b82f6"]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "0a0a1a").ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Handle
|
|
||||||
RoundedRectangle(cornerRadius: 3)
|
RoundedRectangle(cornerRadius: 3)
|
||||||
.fill(Color.white.opacity(0.2))
|
.fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
||||||
.frame(width: 40, height: 4)
|
|
||||||
.padding(.top, 12)
|
|
||||||
|
|
||||||
// Header
|
|
||||||
HStack {
|
HStack {
|
||||||
Button("Отмена") { isPresented = false }
|
Button("Отмена") { isPresented = false }.foregroundColor(Color(hex: "8888aa"))
|
||||||
.foregroundColor(Color(hex: "8888aa"))
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("Новая задача").font(.headline).foregroundColor(.white)
|
Text("Новая задача").font(.headline).foregroundColor(.white)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: save) {
|
Button(action: save) {
|
||||||
if isLoading { ProgressView().tint(Color(hex: "00d4aa")).scaleEffect(0.8) }
|
if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) }
|
||||||
else { Text("Добавить").foregroundColor(title.isEmpty ? Color(hex: "8888aa") : Color(hex: "00d4aa")).fontWeight(.semibold) }
|
else { Text("Добавить").foregroundColor(title.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) }
|
||||||
}
|
}.disabled(title.isEmpty || isLoading)
|
||||||
.disabled(title.isEmpty || isLoading)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20).padding(.vertical, 16)
|
||||||
.padding(.vertical, 16)
|
|
||||||
|
|
||||||
Divider().background(Color.white.opacity(0.1))
|
Divider().background(Color.white.opacity(0.1))
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
// Title field
|
// Title
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label("Название", systemImage: "pencil").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
Label("Название", systemImage: "pencil").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
TextField("Что нужно сделать?", text: $title, axis: .vertical)
|
TextField("Что нужно сделать?", text: $title, axis: .vertical)
|
||||||
.lineLimit(1...3)
|
.lineLimit(1...3).foregroundColor(.white).padding(14)
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(14)
|
|
||||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
||||||
}
|
}
|
||||||
|
// Description
|
||||||
// Description field
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label("Описание", systemImage: "text.alignleft").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
Label("Описание", systemImage: "text.alignleft").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
TextField("Дополнительные детали...", text: $description, axis: .vertical)
|
TextField("Детали...", text: $description, axis: .vertical)
|
||||||
.lineLimit(2...5)
|
.lineLimit(2...4).foregroundColor(.white).padding(14)
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(14)
|
|
||||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
||||||
}
|
}
|
||||||
|
// Priority
|
||||||
// Priority selector
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label("Приоритет", systemImage: "flag.fill").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
Label("Приоритет", systemImage: "flag.fill").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(priorities, id: \.0) { p in
|
ForEach(priorities, id: \.0) { p in
|
||||||
Button(action: { priority = p.0 }) {
|
Button(action: { priority = p.0 }) {
|
||||||
Text(p.1)
|
Text(p.1).font(.caption.bold())
|
||||||
.font(.caption.bold())
|
|
||||||
.foregroundColor(priority == p.0 ? .black : Color(hex: p.2))
|
.foregroundColor(priority == p.0 ? .black : Color(hex: p.2))
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||||
.padding(.vertical, 8)
|
.background(RoundedRectangle(cornerRadius: 20).fill(priority == p.0 ? Color(hex: p.2) : Color(hex: p.2).opacity(0.15)))
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 20)
|
|
||||||
.fill(priority == p.0 ? Color(hex: p.2) : Color(hex: p.2).opacity(0.15))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// Due Date
|
||||||
.padding(20)
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Label("Срок выполнения", systemImage: "calendar").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
Spacer()
|
||||||
|
Toggle("", isOn: $hasDueDate).tint(Color(hex: "0D9488")).labelsHidden()
|
||||||
|
}
|
||||||
|
if hasDueDate {
|
||||||
|
DatePicker("", selection: $dueDate, in: Date()..., displayedComponents: .date)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.colorInvert()
|
||||||
|
.colorMultiply(Color(hex: "0D9488"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Icon
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label("Иконка", systemImage: "face.smiling").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 8) {
|
||||||
|
ForEach(icons, id: \.self) { icon in
|
||||||
|
Button(action: { selectedIcon = icon }) {
|
||||||
|
Text(icon).font(.title3)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(Circle().fill(selectedIcon == icon ? Color(hex: "0D9488").opacity(0.25) : Color.white.opacity(0.05)))
|
||||||
|
.overlay(Circle().stroke(selectedIcon == icon ? Color(hex: "0D9488") : Color.clear, lineWidth: 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Color
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label("Цвет", systemImage: "paintpalette").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ForEach(colors, id: \.self) { c in
|
||||||
|
Button(action: { selectedColor = c }) {
|
||||||
|
Circle().fill(Color(hex: String(c.dropFirst()))).frame(width: 32, height: 32)
|
||||||
|
.overlay(Circle().stroke(.white, lineWidth: selectedColor == c ? 2 : 0))
|
||||||
|
.scaleEffect(selectedColor == c ? 1.15 : 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.padding(20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func save() {
|
func save() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
|
||||||
|
let dueDateStr = hasDueDate ? df.string(from: dueDate) : nil
|
||||||
Task {
|
Task {
|
||||||
let req = CreateTaskRequest(
|
let req = CreateTaskRequest(
|
||||||
title: title,
|
title: title,
|
||||||
description: description.isEmpty ? nil : description,
|
description: description.isEmpty ? nil : description,
|
||||||
priority: priority
|
priority: priority,
|
||||||
|
dueDate: dueDateStr,
|
||||||
|
icon: selectedIcon,
|
||||||
|
color: selectedColor
|
||||||
)
|
)
|
||||||
try? await APIService.shared.createTask(token: authManager.token, request: req)
|
try? await APIService.shared.createTask(token: authManager.token, request: req)
|
||||||
await onAdded()
|
await onAdded()
|
||||||
|
|||||||
701
PulseHealth/Views/Tracker/TrackerView.swift
Normal file
701
PulseHealth/Views/Tracker/TrackerView.swift
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ targets:
|
|||||||
SWIFT_VERSION: 5.9
|
SWIFT_VERSION: 5.9
|
||||||
INFOPLIST_FILE: PulseHealth/Info.plist
|
INFOPLIST_FILE: PulseHealth/Info.plist
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
CODE_SIGN_ENTITLEMENTS: PulseHealth/PulseHealth.entitlements
|
||||||
entitlements:
|
entitlements:
|
||||||
path: PulseHealth/PulseHealth.entitlements
|
path: PulseHealth/PulseHealth.entitlements
|
||||||
capabilities:
|
capabilities:
|
||||||
|
|||||||
Reference in New Issue
Block a user