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:
Cosmo
2026-03-25 17:14:59 +00:00
parent 17b82a874f
commit e7af51af10
18 changed files with 2959 additions and 628 deletions

View File

@@ -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?

View File

@@ -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,8 +48,104 @@ 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?
@@ -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]
}
}

View File

@@ -16,15 +16,18 @@ 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" }
@@ -32,10 +35,19 @@ struct SavingsCategory: Codable, Identifiable {
} }
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 {
@@ -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"
} }
} }
@@ -69,6 +82,15 @@ struct SavingsTransaction: Codable, Identifiable {
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"
} }
} }

View File

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

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

View File

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

View File

@@ -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"))
Spacer()
Text("\(completedHabitsToday)/\(totalHabitsToday) привычек")
.font(.caption).foregroundColor(Color(hex: "0D9488"))
}
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)
// MARK: Stat Cards
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
DashStatCard(icon: "checkmark.circle.fill", value: "\(completedHabitsToday)", label: "Выполнено сегодня", color: "0D9488")
DashStatCard(icon: "flame.fill", value: "\(habitsStats?.activeHabits ?? totalHabitsToday)", label: "Активных привычек", color: "ffa502")
DashStatCard(icon: "calendar", value: "\(todayTasks.count)", label: "Задач на сегодня", color: "6366f1")
DashStatCard(icon: "checkmark.seal.fill", value: "\(completedTodayTasksCount)", label: "Задач выполнено", color: "10b981")
}
.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: "Нет привычек на сегодня")
} }
// Stats row // MARK: Today's Tasks
HStack(spacing: 12) { VStack(alignment: .leading, spacing: 10) {
StatCard(icon: "checkmark.circle.fill", value: "\(pendingTasks.count)", label: "Задач", color: "00d4aa") HStack {
StatCard(icon: "flame.fill", value: "\(completedHabitsToday)/\(habits.count)", label: "Привычек", color: "ffa502") Text("Задачи на сегодня")
if let r = readiness { .font(.headline).foregroundColor(.white)
StatCard(icon: "heart.fill", value: "\(r.score)", label: "Готовность", color: r.score >= 80 ? "00d4aa" : r.score >= 60 ? "ffa502" : "ff4757") Spacer()
Button(action: { addMode = .task; showAddSheet = true }) {
Image(systemName: "plus.circle.fill")
.foregroundColor(Color(hex: "0D9488"))
} }
} }
.padding(.horizontal) .padding(.horizontal)
// Today's tasks if todayTasks.isEmpty {
if !pendingTasks.isEmpty { EmptyState(icon: "checkmark.circle", text: "Нет задач на сегодня")
VStack(alignment: .leading, spacing: 12) { } else {
Text("Задачи на сегодня").font(.headline).foregroundColor(.white).padding(.horizontal) ForEach(todayTasks) { task in
ForEach(pendingTasks.prefix(3)) { task in DashTaskRow(
TaskRowView(task: task) { task: task,
await completeTask(task) isUndoVisible: recentlyCompletedTaskId == task.id,
onToggle: { await toggleTask(task) },
onUndo: { await undoTask(task) }
)
} }
} }
} }
} }
Spacer(minLength: 80)
// Habits progress
if !habits.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("Привычки сегодня").font(.headline).foregroundColor(.white).padding(.horizontal)
ForEach(habits.prefix(4)) { habit in
HabitRowView(habit: habit) {
await logHabit(habit)
}
}
}
}
}
Spacer(minLength: 20)
} }
} }
.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 logHabit(_ habit: Habit) async { func toggleHabit(_ habit: Habit) async {
try? await APIService.shared.logHabit(token: authManager.token, id: habit.id) if habit.completedToday == true {
await loadData() // 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
} }
} }
// MARK: - StatCard func undoHabitLog(_ habit: Habit) async {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
// 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)
}
struct StatCard: View { 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: - DashStatCard
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)))
}
}
// 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(.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)
} }
} }

View File

@@ -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"))
} }
}.padding() Text(monthLabel())
.font(.subheadline).foregroundColor(.white)
.frame(minWidth: 80)
Button(action: { nextMonth() }) {
Image(systemName: "chevron.right").foregroundColor(Color(hex: "8888aa"))
}
}
}
.padding(.horizontal)
.padding(.top, 16)
.padding(.bottom, 12)
if isLoading { Picker("", selection: $selectedTab) {
ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 40) Text("Обзор").tag(0)
Spacer() Text("Транзакции").tag(1)
} else { 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 { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
// Summary card if isLoading {
if let s = summary { ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
FinanceSummaryCard(summary: s) } else {
// Summary Card
if let s = summary { FinanceSummaryCard2(summary: s) }
// Top Expenses
if !expenseByCategory.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("Топ расходов").font(.subheadline.bold()).foregroundColor(.white)
ForEach(expenseByCategory.prefix(5)) { cat in
let total = summary?.totalExpense ?? 1
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)
} }
// Recent transactions // Pie Chart
if !transactions.isEmpty { if expenseByCategory.count > 1 {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Последние операции") Text("Расходы по категориям").font(.subheadline.bold()).foregroundColor(.white)
.font(.headline).foregroundColor(.white).padding(.horizontal) Chart(expenseByCategory) { cat in
ForEach(transactions.prefix(20)) { tx in SectorMark(
TransactionRowView(transaction: tx, categories: categories) 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)
}
}
}
.sheet(isPresented: $showAddTransaction) {
AddTransactionView(isPresented: $showAddTransaction, categories: categories) { await loadData() }
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationBackground(Color(hex: "0a0a1a"))
}
.task { await loadData() }
.refreshable { await loadData(refresh: true) }
} }
func loadData(refresh: Bool = false) async { // 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 } if !refresh { isLoading = true }
async let s = APIService.shared.getFinanceSummary(token: authManager.token) async let s = APIService.shared.getFinanceSummary(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 summary = try? await s
transactions = (try? await t) ?? []
categories = (try? await c) ?? [] categories = (try? await c) ?? []
isLoading = false isLoading = false
} }
func formatAmt(_ v: Double) -> String {
v >= 1000 ? String(format: "%.0f ₽", v) : String(format: "%.0f ₽", v)
}
} }
// MARK: - FinanceSummaryCard // MARK: - FinanceSummaryCard2
struct FinanceSummaryCard: View { struct FinanceSummaryCard2: View {
let summary: FinanceSummary let summary: FinanceSummary
var body: some View { var body: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
HStack(spacing: 20) { 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) { VStack(spacing: 4) {
Text("Доходы").font(.caption).foregroundColor(Color(hex: "8888aa")) Text("Доходы").font(.caption).foregroundColor(Color(hex: "8888aa"))
Text("+\(Int(summary.totalIncome ?? 0))").font(.headline).foregroundColor(Color(hex: "00d4aa")) Text("+\(formatAmt(summary.totalIncome ?? 0))")
} .font(.callout.bold()).foregroundColor(Color(hex: "0D9488"))
Spacer()
VStack(spacing: 4) {
Text("Баланс").font(.subheadline).foregroundColor(Color(hex: "8888aa"))
Text("\(Int(summary.balance ?? 0))").font(.title2.bold()).foregroundColor(.white)
} }
Spacer() Spacer()
VStack(spacing: 4) { VStack(spacing: 4) {
Text("Расходы").font(.caption).foregroundColor(Color(hex: "8888aa")) Text("Расходы").font(.caption).foregroundColor(Color(hex: "8888aa"))
Text("-\(Int(summary.totalExpense ?? 0))").font(.headline).foregroundColor(Color(hex: "ff4757")) 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) .padding(20)
.background(RoundedRectangle(cornerRadius: 20).fill(Color.white.opacity(0.05))) .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) .padding(.horizontal)
} }
func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) }
} }
// MARK: - TransactionRowView // MARK: - FinanceTransactionsTab
struct TransactionRowView: View { struct FinanceTransactionsTab: View {
let transaction: FinanceTransaction @EnvironmentObject var authManager: AuthManager
let categories: [FinanceCategory] 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 category: FinanceCategory? { categories.first { $0.id == transaction.categoryId } } var groupedByDay: [(key: String, value: [FinanceTransaction])] {
var isIncome: Bool { transaction.type == "income" } let grouped = Dictionary(grouping: transactions) { $0.dateOnly }
return grouped.sorted { $0.key > $1.key }
}
var body: some View { var body: some View {
HStack { ZStack(alignment: .bottomTrailing) {
Text(category?.icon ?? (isIncome ? "💰" : "💸")).font(.title2) 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)
}
.task { await load() }
.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])
.presentationDragIndicator(.visible)
.presentationBackground(Color(hex: "0a0a1a"))
}
}
func load(refresh: Bool = false) async {
if !refresh { isLoading = true }
async let t = APIService.shared.getTransactions(token: authManager.token, month: month, year: year)
async let c = APIService.shared.getFinanceCategories(token: authManager.token)
transactions = (try? await t) ?? []
categories = (try? await c) ?? []
isLoading = false
}
func formatSectionDate(_ s: String) -> String {
let parts = s.split(separator: "-")
guard parts.count == 3 else { return s }
return "\(parts[2]).\(parts[1]).\(parts[0])"
}
}
// MARK: - FinanceTxRow
struct FinanceTxRow: View {
let transaction: FinanceTransaction
let categories: [FinanceCategory]
var cat: FinanceCategory? { categories.first { $0.id == transaction.categoryId } }
var isIncome: Bool { transaction.type == "income" }
var body: some View {
HStack(spacing: 12) {
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) }
} }

View File

@@ -9,47 +9,45 @@ struct AddHabitView: View {
@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,7 +62,8 @@ 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()
} }
@@ -86,30 +85,57 @@ 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))
} }
} }
} }
@@ -131,8 +157,7 @@ struct AddHabitView: View {
} }
} }
} }
} }.padding(20)
.padding(20)
} }
} }
} }
@@ -141,11 +166,7 @@ struct AddHabitView: View {
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 }
} }

View File

@@ -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)

View File

@@ -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"))
} }
} }

View File

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

View File

@@ -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)
Text("Смена пароля").font(.title3.bold()).foregroundColor(.white).padding(.top, 4)
if success { if success {
VStack(spacing: 12) { VStack(spacing: 12) {
Text("").font(.system(size: 50)) Text("").font(.system(size: 50))
Text("Пароль изменён!").font(.title2.bold()).foregroundColor(.white) Text("Пароль изменён!").font(.title2.bold()).foregroundColor(.white)
}.padding(.top, 40) }.padding(.top, 40)
Button("Закрыть") { isPresented = false }.foregroundColor(Color(hex: "00d4aa")) Button("Закрыть") { isPresented = false }.foregroundColor(Color(hex: "0D9488"))
} else { } 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,17 +49,15 @@ 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)
} }
Button("Отмена") { isPresented = false }
.foregroundColor(Color(hex: "8888aa")).padding(.top, 4)
}
Spacer() Spacer()
}.padding() }.padding()
} }
.navigationTitle("Смена пароля")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Отмена") { isPresented = false } } }
.preferredColorScheme(.dark)
}
} }
func change() { func change() {
@@ -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
} }
} }

View File

@@ -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)
.padding(.top, 16)
.padding(.bottom, 12)
// Segment control
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 {
if isLoading {
ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 40)
Spacer()
} else {
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
if let s = stats { SavingsTotalCard(stats: s) } if isLoading {
VStack(spacing: 10) { ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
ForEach(categories) { cat in SavingsCategoryCard(category: cat) } } else {
}.padding(.horizontal) // Total Balance Card
if let s = stats {
VStack(spacing: 16) {
VStack(spacing: 6) {
Text("Общий баланс").font(.subheadline).foregroundColor(Color(hex: "8888aa"))
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)
.task { await loadData() } .background(LinearGradient(colors: [Color(hex: "1a1a3e"), Color(hex: "12122a")], startPoint: .topLeading, endPoint: .bottomTrailing))
.refreshable { await loadData(refresh: true) } .cornerRadius(20)
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "0D9488").opacity(0.3), lineWidth: 1))
.padding(.horizontal)
} }
func loadData(refresh: Bool = false) async { // 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 load() }
.refreshable { await load(refresh: true) }
}
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 {
ZStack(alignment: .bottomTrailing) {
Group { Group {
if isLoading { if isLoading {
ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 40) ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
Spacer()
} else if transactions.isEmpty {
VStack(spacing: 12) {
Text("💸").font(.system(size: 50))
Text("Нет транзакций").foregroundColor(Color(hex: "8888aa"))
}.padding(.top, 60)
Spacer() Spacer()
} else if active.isEmpty {
VStack { EmptyState(icon: "building.columns", text: "Нет категорий"); Spacer() }
} else { } else {
List { List {
ForEach(transactions) { tx in Section(header: Text("Активные").foregroundColor(Color(hex: "8888aa"))) {
SavingsTransactionRow(transaction: tx) ForEach(active) { cat in
SavingsCategoryRow(category: cat)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.onTapGesture { editingCategory = cat }
}
.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) .listStyle(.plain)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
} }
} }
.task { await loadData() } .refreshable { await load(refresh: true) }
.refreshable { await loadData(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"))
}
} }
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
let transaction: SavingsTransaction
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
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 { // MARK: - AddSavingsTransactionView
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 struct AddSavingsTransactionView: View {
@Binding var isPresented: Bool
@EnvironmentObject var authManager: AuthManager
let categories: [SavingsCategory]
let onAdded: () async -> Void
struct SavingsTotalCard: View { @State private var amount = ""
let stats: SavingsStats @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" }
var body: some View { 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((amount.isEmpty || selectedCategoryId == nil) ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) }
}.disabled(amount.isEmpty || selectedCategoryId == nil || isLoading)
}
.padding(.horizontal, 20).padding(.vertical, 16)
Divider().background(Color.white.opacity(0.1))
ScrollView {
VStack(spacing: 20) { VStack(spacing: 20) {
VStack(spacing: 6) { // Type toggle
Text("Общий баланс").font(.subheadline).foregroundColor(Color(hex: "8888aa"))
Text(formatAmount(stats.totalBalance ?? 0))
.font(.system(size: 36, weight: .bold))
.foregroundColor(.white)
}
HStack(spacing: 0) { HStack(spacing: 0) {
VStack(spacing: 4) { Button(action: { type = "deposit" }) {
Text("Пополнения").font(.caption).foregroundColor(Color(hex: "8888aa")) Text("Пополнение")
Text("+\(formatAmount(stats.totalDeposits ?? 0))").font(.callout.bold()).foregroundColor(Color(hex: "00d4aa")) .font(.callout.bold()).foregroundColor(isDeposit ? .black : Color(hex: "0D9488"))
.frame(maxWidth: .infinity).padding(.vertical, 12)
.background(isDeposit ? Color(hex: "0D9488") : Color.clear)
} }
Spacer() Button(action: { type = "withdrawal" }) {
VStack(spacing: 4) { Text("Снятие ↑")
Text("Снятия").font(.caption).foregroundColor(Color(hex: "8888aa")) .font(.callout.bold()).foregroundColor(!isDeposit ? .black : Color(hex: "ff4757"))
Text("-\(formatAmount(stats.totalWithdrawals ?? 0))").font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) .frame(maxWidth: .infinity).padding(.vertical, 12)
} .background(!isDeposit ? Color(hex: "ff4757") : Color.clear)
Spacer()
VStack(spacing: 4) {
Text("Категорий").font(.caption).foregroundColor(Color(hex: "8888aa"))
Text("\(stats.categoriesCount ?? 0)").font(.callout.bold()).foregroundColor(.white)
} }
} }
.background(Color.white.opacity(0.07)).cornerRadius(12)
HStack {
Text(isDeposit ? "+" : "").font(.title.bold()).foregroundColor(isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757"))
TextField("0", text: $amount).keyboardType(.decimalPad)
.font(.system(size: 32, weight: .bold)).foregroundColor(.white).multilineTextAlignment(.center)
Text("").font(.title.bold()).foregroundColor(Color(hex: "8888aa"))
} }
.padding(20) .padding(20)
.background( .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.07)))
LinearGradient(colors: [Color(hex: "1a1a3e"), Color(hex: "12122a")], startPoint: .topLeading, endPoint: .bottomTrailing)
)
.cornerRadius(20)
.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"))
}
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() Spacer()
if selectedCategoryId == cat.id {
VStack(alignment: .trailing, spacing: 2) { Image(systemName: "checkmark").foregroundColor(Color(hex: "0D9488"))
Text(formatAmount(category.currentAmount ?? 0)) }
.font(.callout.bold()) }
.foregroundColor(Color(hex: category.color)) .padding(12)
if let end = category.depositEndDate { .background(RoundedRectangle(cornerRadius: 12).fill(selectedCategoryId == cat.id ? Color(hex: "0D9488").opacity(0.15) : Color.white.opacity(0.05)))
Text("до \(formatDate(end))")
.font(.caption2)
.foregroundColor(Color(hex: "8888aa"))
} }
} }
} }
if category.isDeposit == true, let target = category.depositAmount, target > 0 { VStack(alignment: .leading, spacing: 8) {
let progress = min((category.currentAmount ?? 0) / target, 1.0) Label("Описание", systemImage: "text.alignleft").font(.caption).foregroundColor(Color(hex: "8888aa"))
VStack(spacing: 4) { TextField("Комментарий...", text: $description)
GeometryReader { geo in .foregroundColor(.white).padding(14)
ZStack(alignment: .leading) { .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.08)) }
RoundedRectangle(cornerRadius: 4) }.padding(20)
.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)
} }
} }
.frame(height: 6)
HStack {
Text("\(Int(progress * 100))%")
.font(.caption2).foregroundColor(Color(hex: "ffa502"))
Spacer()
Text("цель: \(formatAmount(target))")
.font(.caption2).foregroundColor(Color(hex: "8888aa"))
}
}
}
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color.white.opacity(0.04))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color(hex: category.color).opacity(0.15), lineWidth: 1))
)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 20)
.onAppear {
withAnimation(.easeOut(duration: 0.4)) { appeared = true }
} }
} }
func formatAmount(_ v: Double) -> String { func save() {
if v >= 1_000_000 { return String(format: "%.2f млн ₽", v / 1_000_000) } guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")),
return String(format: "%.0f ₽", v) let cid = selectedCategoryId else { return }
} isLoading = true
Task {
func formatDate(_ s: String) -> String { let req = CreateSavingsTransactionRequest(categoryId: cid, amount: a, type: type, description: description.isEmpty ? nil : description)
let parts = s.prefix(10).split(separator: "-") try? await APIService.shared.createSavingsTransaction(token: authManager.token, request: req)
guard parts.count == 3 else { return s } await onAdded()
return "\(parts[2]).\(parts[1]).\(parts[0])" await MainActor.run { isPresented = false }
}
} }
} }

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

View File

@@ -8,6 +8,10 @@ struct AddTaskView: View {
@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)] = [
@@ -17,78 +21,102 @@ struct AddTaskView: View {
(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(.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: 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
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("Срок выполнения", systemImage: "calendar").font(.caption).foregroundColor(Color(hex: "8888aa"))
Spacer()
Toggle("", isOn: $hasDueDate).tint(Color(hex: "0D9488")).labelsHidden()
} }
.padding(20) 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)
} }
} }
} }
@@ -96,11 +124,16 @@ struct AddTaskView: View {
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()

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

View File

@@ -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: