import SwiftUI // MARK: - SavingsView struct SavingsView: View { @State private var selectedTab = 0 var body: some View { ZStack { Color(hex: "0a0a1a").ignoresSafeArea() VStack(spacing: 0) { 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: SavingsOverviewTab2() case 1: SavingsCategoriesTab() default: SavingsOperationsTab() } } } } } // MARK: - SavingsOverviewTab2 struct SavingsOverviewTab2: View { @EnvironmentObject var authManager: AuthManager @State private var categories: [SavingsCategory] = [] @State private var stats: SavingsStats? @State private var isLoading = true var recurringCategories: [SavingsCategory] { categories.filter { $0.isRecurring == true } } var hasOverdue: Bool { (stats?.overdueCount ?? 0) > 0 } var body: some View { ScrollView { VStack(spacing: 16) { if isLoading { ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) } else { // 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) .background(LinearGradient(colors: [Color(hex: "1a1a3e"), Color(hex: "12122a")], startPoint: .topLeading, endPoint: .bottomTrailing)) .cornerRadius(20) .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "0D9488").opacity(0.3), lineWidth: 1)) .padding(.horizontal) } // Overdue block if hasOverdue, let s = stats { HStack(spacing: 12) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(Color(hex: "ff4757")) .font(.title3) VStack(alignment: .leading, spacing: 2) { Text("Просроченные платежи").font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) Text("\(s.overdueCount ?? 0) платежей на сумму \(formatAmt(s.overdueAmount ?? 0))") .font(.caption).foregroundColor(.white.opacity(0.7)) } Spacer() } .padding(16) .background(RoundedRectangle(cornerRadius: 14).fill(Color(hex: "ff4757").opacity(0.12))) .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "ff4757").opacity(0.3), lineWidth: 1)) .padding(.horizontal) } // Categories progress if !categories.isEmpty { VStack(alignment: .leading, spacing: 10) { Text("Прогресс по категориям") .font(.subheadline.bold()).foregroundColor(.white).padding(.horizontal) ForEach(categories.filter { $0.isClosed != true }) { cat in SavingsCategoryCard(category: cat) } } } // Monthly payments if !recurringCategories.isEmpty { VStack(alignment: .leading, spacing: 10) { Text("Ежемесячные платежи") .font(.subheadline.bold()).foregroundColor(.white).padding(.horizontal) ForEach(recurringCategories) { cat in HStack(spacing: 12) { ZStack { Circle().fill(Color(hex: cat.colorHex).opacity(0.15)).frame(width: 40, height: 40) Image(systemName: cat.icon).foregroundColor(Color(hex: cat.colorHex)).font(.body) } VStack(alignment: .leading, spacing: 2) { Text(cat.name).font(.callout).foregroundColor(.white) if let day = cat.recurringDay { Text("\(day) числа каждого месяца").font(.caption2).foregroundColor(Color(hex: "8888aa")) } } Spacer() Text(formatAmt(cat.recurringAmount ?? 0)) .font(.callout.bold()).foregroundColor(Color(hex: cat.colorHex)) } .padding(14) .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.04))) .padding(.horizontal) } } } } Spacer(minLength: 80) } .padding(.top, 8) } .task { await load() } .refreshable { await load(refresh: true) } } func load(refresh: Bool = false) async { if !refresh { isLoading = true } async let cats = APIService.shared.getSavingsCategories(token: authManager.token) async let st = APIService.shared.getSavingsStats(token: authManager.token) categories = (try? await cats) ?? [] stats = try? await st isLoading = false } func formatAmt(_ v: Double) -> String { v >= 1_000_000 ? String(format: "%.2f млн ₽", v / 1_000_000) : String(format: "%.0f ₽", v) } } // MARK: - SavingsCategoriesTab struct SavingsCategoriesTab: View { @EnvironmentObject var authManager: AuthManager @State private var categories: [SavingsCategory] = [] @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 { ZStack(alignment: .bottomTrailing) { Group { if isLoading { ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) Spacer() } else if active.isEmpty { VStack { EmptyState(icon: "building.columns", text: "Нет категорий"); Spacer() } } else { List { Section(header: Text("Активные").foregroundColor(Color(hex: "8888aa"))) { ForEach(active) { cat in SavingsCategoryRow(category: cat) .listRowBackground(Color.clear) .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) .scrollContentBackground(.hidden) } } .refreshable { await load(refresh: true) } Button(action: { showAdd = true }) { ZStack { Circle() .fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(width: 56, height: 56) .shadow(color: Color(hex: "0D9488").opacity(0.4), radius: 8, y: 4) Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white) } } .padding(.bottom, 90) .padding(.trailing, 20) } .task { await load() } .sheet(isPresented: $showAdd) { AddSavingsCategoryView(isPresented: $showAdd) { await load(refresh: true) } .presentationDetents([.large]) .presentationDragIndicator(.visible) .presentationBackground(Color(hex: "0a0a1a")) } } func load(refresh: Bool = false) async { if !refresh { isLoading = true } categories = (try? await APIService.shared.getSavingsCategories(token: authManager.token)) ?? [] isLoading = false } } // MARK: - SavingsCategoryRow struct SavingsCategoryRow: View { let category: SavingsCategory var body: some View { HStack(spacing: 12) { ZStack { Circle().fill(Color(hex: category.colorHex).opacity(0.15)).frame(width: 44, height: 44) Image(systemName: category.icon).foregroundColor(Color(hex: category.colorHex)).font(.title3) } VStack(alignment: .leading, spacing: 3) { HStack(spacing: 4) { Text(category.typeEmoji) Text(category.name).font(.callout.bold()).foregroundColor(.white) } Text(category.typeLabel).font(.caption).foregroundColor(Color(hex: "8888aa")) } Spacer() Text(formatAmt(category.currentAmount ?? 0)) .font(.callout.bold()).foregroundColor(Color(hex: category.colorHex)) } .padding(14) .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.04))) .padding(.horizontal) .padding(.vertical, 2) } func formatAmt(_ v: Double) -> String { v >= 1_000_000 ? String(format: "%.2f млн ₽", v / 1_000_000) : String(format: "%.0f ₽", v) } } // MARK: - AddSavingsCategoryView struct AddSavingsCategoryView: View { @Binding var isPresented: Bool @EnvironmentObject var authManager: AuthManager let onAdded: () async -> Void @State private var name = "" @State private var isDeposit = false @State private var isRecurring = false @State private var isAccount = false @State private var recurringAmount = "" @State private var interestRate = "" @State private var isLoading = false var body: some View { ZStack { Color(hex: "0a0a1a").ignoresSafeArea() VStack(spacing: 0) { RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12) HStack { Button("Отмена") { isPresented = false }.foregroundColor(Color(hex: "8888aa")) Spacer() Text("Новая категория").font(.headline).foregroundColor(.white) Spacer() Button(action: save) { if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) } else { Text("Добавить").foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) } }.disabled(name.isEmpty || isLoading) } .padding(.horizontal, 20).padding(.vertical, 16) Divider().background(Color.white.opacity(0.1)) ScrollView { VStack(spacing: 16) { fieldLabel("Название") { TextField("Например: На машину", text: $name) .foregroundColor(.white).padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) } VStack(alignment: .leading, spacing: 8) { Label("Тип", systemImage: "tag.fill").font(.caption).foregroundColor(Color(hex: "8888aa")) HStack(spacing: 8) { TypeButton(label: "💰 Накопление", selected: !isDeposit && !isRecurring && !isAccount) { isDeposit = false; isRecurring = false; isAccount = false } TypeButton(label: "🏦 Вклад", selected: isDeposit) { isDeposit = true; isRecurring = false; isAccount = false } } HStack(spacing: 8) { TypeButton(label: "🔄 Регулярные", selected: isRecurring) { isDeposit = false; isRecurring = true; isAccount = false } TypeButton(label: "🏧 Счёт", selected: isAccount) { isDeposit = false; isRecurring = false; isAccount = true } } } if isRecurring { fieldLabel("Сумма / мес. (₽)") { TextField("0", text: $recurringAmount).keyboardType(.decimalPad) .foregroundColor(.white).padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) } } if isDeposit { fieldLabel("Ставка (%)") { TextField("0.0", text: $interestRate).keyboardType(.decimalPad) .foregroundColor(.white).padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) } } }.padding(20) } } } } @ViewBuilder func fieldLabel(_ 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 { HStack(spacing: 12) { ZStack { Circle() .fill((transaction.isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757")).opacity(0.15)) .frame(width: 40, height: 40) Image(systemName: transaction.isDeposit ? "arrow.down.circle.fill" : "arrow.up.circle.fill") .foregroundColor(transaction.isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757")) } VStack(alignment: .leading, spacing: 2) { Text(transaction.categoryName ?? "Без категории").font(.callout).foregroundColor(.white) HStack(spacing: 6) { if let name = transaction.userName { Text(name).font(.caption).foregroundColor(Color(hex: "8888aa")) } Text(transaction.dateFormatted).font(.caption2).foregroundColor(Color(hex: "8888aa")) } if let desc = transaction.description, !desc.isEmpty { Text(desc).font(.caption2).foregroundColor(Color(hex: "8888aa")) } } Spacer() Text("\(transaction.isDeposit ? "+" : "-")\(formatAmt(transaction.amount))") .font(.callout.bold()) .foregroundColor(transaction.isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757")) } .padding(12) .background(RoundedRectangle(cornerRadius: 12).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: - AddSavingsTransactionView struct AddSavingsTransactionView: View { @Binding var isPresented: Bool @EnvironmentObject var authManager: AuthManager let categories: [SavingsCategory] let onAdded: () async -> Void @State private var amount = "" @State private var description = "" @State private var type = "deposit" @State private var selectedCategoryId: Int? = nil @State private var isLoading = false var isDeposit: Bool { type == "deposit" } 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) { // Type toggle HStack(spacing: 0) { Button(action: { type = "deposit" }) { Text("Пополнение ↓") .font(.callout.bold()).foregroundColor(isDeposit ? .black : Color(hex: "0D9488")) .frame(maxWidth: .infinity).padding(.vertical, 12) .background(isDeposit ? Color(hex: "0D9488") : Color.clear) } Button(action: { type = "withdrawal" }) { Text("Снятие ↑") .font(.callout.bold()).foregroundColor(!isDeposit ? .black : Color(hex: "ff4757")) .frame(maxWidth: .infinity).padding(.vertical, 12) .background(!isDeposit ? Color(hex: "ff4757") : Color.clear) } } .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) .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.07))) VStack(alignment: .leading, spacing: 8) { Label("Категория", systemImage: "tag.fill").font(.caption).foregroundColor(Color(hex: "8888aa")) ForEach(categories.filter { $0.isClosed != true }) { cat in Button(action: { selectedCategoryId = selectedCategoryId == cat.id ? nil : cat.id }) { HStack(spacing: 10) { Image(systemName: cat.icon).foregroundColor(Color(hex: cat.colorHex)).font(.body) Text(cat.name).font(.callout).foregroundColor(.white) Spacer() if selectedCategoryId == cat.id { Image(systemName: "checkmark").foregroundColor(Color(hex: "0D9488")) } } .padding(12) .background(RoundedRectangle(cornerRadius: 12).fill(selectedCategoryId == cat.id ? Color(hex: "0D9488").opacity(0.15) : Color.white.opacity(0.05))) } } } VStack(alignment: .leading, spacing: 8) { Label("Описание", systemImage: "text.alignleft").font(.caption).foregroundColor(Color(hex: "8888aa")) TextField("Комментарий...", text: $description) .foregroundColor(.white).padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) } }.padding(20) } } } } func save() { guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")), let cid = selectedCategoryId else { return } isLoading = true Task { let req = CreateSavingsTransactionRequest(categoryId: cid, amount: a, type: type, description: description.isEmpty ? nil : description) try? await APIService.shared.createSavingsTransaction(token: authManager.token, request: req) await onAdded() await MainActor.run { isPresented = false } } } }