Files
pulse-mobile/PulseHealth/Views/Savings/SavingsView.swift
Daniil Klimov 28fca1de89 feat: major app overhaul — API fixes, glassmorphism UI, health dashboard, notifications
API Integration:
- Fix logHabit: send "date" instead of "completed_at"
- Fix FinanceCategory: "icon" → "emoji" to match API
- Fix task priorities: remove level 4, keep 1-3 matching API
- Fix habit frequencies: map monthly/interval → "custom" for API
- Add token refresh (401 → auto retry with new token)
- Add proper error handling (remove try? in save functions, show errors in UI)
- Add date field to savings transactions
- Add MonthlyPaymentDetail and OverduePayment models
- Fix habit completedToday: compute on client from logs (API doesn't return it)
- Filter habits by day of week on client (daily/weekly/monthly/interval)

Design System (glassmorphism):
- New DesignSystem.swift: Theme colors, GlassCard modifier, GlowIcon, GlowStatCard
- Custom tab bar with per-tab glow colors (VStack layout, not ZStack overlay)
- Deep dark background #06060f across all views
- Glass cards with gradient fill + stroke throughout app
- App icon: glassmorphism style with teal glow

Health Dashboard:
- Compact ReadinessBanner with recommendation text
- 8 metric tiles: sleep, HR, HRV, steps, SpO2, respiratory rate, energy, distance
- Each tile with status indicator (good/ok/bad) and hint text
- Heart rate card (min/avg/max)
- Weekly trends card (averages)
- Recovery score (weighted: 40% sleep, 35% HRV, 25% RHR)
- Tips card with actionable recommendations
- Sleep detail view with hypnogram (step chart of phases)
- Sleep segments timeline from HealthKit (deep/rem/core/awake with exact times)
- Line chart replacing bar chart for weekly data
- Collect respiratory_rate and sleep phases with timestamps from HealthKit
- Background sync every ~30min via BGProcessingTask

Notifications:
- NotificationService for local push notifications
- Morning/evening reminders with native DatePicker (wheel)
- Payment reminders: 5 days, 1 day, and day-of for recurring savings
- Notification settings in Settings tab

UI Fixes:
- Fix color picker overflow: HStack → LazyVGrid 5 columns
- Fix sheet headers: shorter text, proper padding
- Fix task/habit toggle: separate tap zones (checkbox vs edit)
- Fix deprecated onChange syntax for iOS 17+
- Savings overview: real monthly payments and detailed overdues from API
- Settings: timezone as Menu picker, removed Telegram/server notifications sections
- All sheets use .presentationDetents([.large])

Config:
- project.yml: real DEVELOPMENT_TEAM, HealthKit + BackgroundModes capabilities
- Info.plist: BGTaskScheduler + UIBackgroundModes
- Assets.xcassets with AppIcon
- CLAUDE.md project documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:15:36 +03:00

1092 lines
54 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
// MARK: - SavingsView
struct SavingsView: View {
@State private var selectedTab = 0
var body: some View {
ZStack {
Color(hex: "06060f").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 monthlyDetails: [MonthlyPaymentDetail] { stats?.monthlyPaymentDetails ?? [] }
var overdues: [OverduePayment] { stats?.overdues ?? [] }
var hasOverdue: Bool { !overdues.isEmpty }
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)
}
// Monthly payments from API
if !monthlyDetails.isEmpty {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Ежемесячные платежи")
.font(.subheadline.bold()).foregroundColor(.white)
Spacer()
Text(formatAmt(stats?.monthlyPayments ?? 0))
.font(.callout.bold()).foregroundColor(Color(hex: "ffa502"))
}
.padding(.horizontal)
ForEach(monthlyDetails) { detail in
HStack(spacing: 12) {
ZStack {
Circle().fill(Color(hex: "ffa502").opacity(0.15)).frame(width: 40, height: 40)
Image(systemName: "calendar.badge.clock").foregroundColor(Color(hex: "ffa502")).font(.body)
}
VStack(alignment: .leading, spacing: 2) {
Text(detail.categoryName).font(.callout).foregroundColor(.white)
Text("\(detail.day) числа каждого месяца").font(.caption2).foregroundColor(Color(hex: "8888aa"))
}
Spacer()
Text(formatAmt(detail.amount))
.font(.callout.bold()).foregroundColor(Color(hex: "ffa502"))
}
.padding(14)
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.04)))
.padding(.horizontal)
}
}
}
// Overdues detailed list
if hasOverdue {
VStack(alignment: .leading, spacing: 10) {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color(hex: "ff4757"))
Text("Просрочки")
.font(.subheadline.bold()).foregroundColor(Color(hex: "ff4757"))
Spacer()
Text(formatAmt(stats?.overdueAmount ?? 0))
.font(.callout.bold()).foregroundColor(Color(hex: "ff4757"))
}
.padding(.horizontal)
ForEach(overdues) { overdue in
HStack(spacing: 12) {
ZStack {
Circle().fill(Color(hex: "ff4757").opacity(0.15)).frame(width: 40, height: 40)
Image(systemName: "exclamationmark.circle.fill").foregroundColor(Color(hex: "ff4757")).font(.body)
}
VStack(alignment: .leading, spacing: 2) {
Text(overdue.categoryName).font(.callout).foregroundColor(.white)
HStack(spacing: 6) {
Text(overdue.month)
.font(.caption2.bold())
.foregroundColor(Color(hex: "ff4757"))
.padding(.horizontal, 6).padding(.vertical, 2)
.background(RoundedRectangle(cornerRadius: 4).fill(Color(hex: "ff4757").opacity(0.15)))
Text("\(overdue.daysOverdue) дн. просрочки")
.font(.caption2).foregroundColor(Color(hex: "8888aa"))
}
}
Spacer()
Text(formatAmt(overdue.amount))
.font(.callout.bold()).foregroundColor(Color(hex: "ff4757"))
}
.padding(14)
.background(RoundedRectangle(cornerRadius: 14).fill(Color(hex: "ff4757").opacity(0.06)))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "ff4757").opacity(0.2), 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)
}
}
}
}
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?
@State private var recurringPlansCategory: 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, showRecurringButton: cat.isRecurring == true) {
recurringPlansCategory = 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: "06060f"))
}
.sheet(item: $editingCategory) { cat in
EditSavingsCategoryView(isPresented: .constant(true), category: cat) { await load(refresh: true) }
.presentationDetents([.large])
.presentationDragIndicator(.visible)
.presentationBackground(Color(hex: "06060f"))
}
.sheet(item: $recurringPlansCategory) { cat in
RecurringPlansView(category: cat)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationBackground(Color(hex: "06060f"))
}
}
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 showRecurringButton: Bool = false
var onRecurringTap: (() -> Void)? = nil
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()
if showRecurringButton {
Button(action: { onRecurringTap?() }) {
Image(systemName: "calendar.badge.clock")
.foregroundColor(Color(hex: "0D9488"))
.font(.callout)
}
.buttonStyle(.plain)
}
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: "06060f").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
@State private var editingTransaction: SavingsTransaction?
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)
.onTapGesture { editingTransaction = tx }
}
.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: "06060f"))
}
.sheet(item: $editingTransaction) { tx in
EditSavingsTransactionView(isPresented: .constant(true), transaction: tx, categories: categories) {
editingTransaction = nil
await load(refresh: true)
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationBackground(Color(hex: "06060f"))
}
}
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 date = Date()
@State private var isLoading = false
@State private var errorMessage: String?
var isDeposit: Bool { type == "deposit" }
var body: some View {
ZStack {
Color(hex: "06060f").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: "calendar").font(.caption).foregroundColor(Color(hex: "8888aa"))
DatePicker("", selection: $date, displayedComponents: .date)
.labelsHidden()
.colorInvert()
.colorMultiply(Color(hex: "0D9488"))
}
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)))
}
if let err = errorMessage {
Text(err)
.font(.caption).foregroundColor(Color(hex: "ff4757"))
.padding(10)
.frame(maxWidth: .infinity)
.background(RoundedRectangle(cornerRadius: 10).fill(Color(hex: "ff4757").opacity(0.1)))
}
}.padding(20)
}
}
}
}
func save() {
guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")),
let cid = selectedCategoryId else { return }
isLoading = true
errorMessage = nil
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
let dateStr = df.string(from: date)
Task {
do {
let req = CreateSavingsTransactionRequest(categoryId: cid, amount: a, type: type, description: description.isEmpty ? nil : description, date: dateStr)
try await APIService.shared.createSavingsTransaction(token: authManager.token, request: req)
await onAdded()
await MainActor.run { isPresented = false }
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
}
}
// MARK: - SavingsCategoryCard
struct SavingsCategoryCard: View {
let category: SavingsCategory
var body: some View {
HStack(spacing: 14) {
ZStack {
Circle()
.fill(Color(hex: category.colorHex).opacity(0.15))
.frame(width: 46, height: 46)
Image(systemName: category.icon)
.foregroundColor(Color(hex: category.colorHex))
.font(.title3)
}
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text(category.typeEmoji)
Text(category.name)
.font(.callout.bold())
.foregroundColor(.white)
}
Text(category.typeLabel)
.font(.caption2)
.foregroundColor(Color(hex: "8888aa"))
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
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)))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color(hex: category.colorHex).opacity(0.15), lineWidth: 1)
)
.padding(.horizontal)
}
func formatAmt(_ v: Double) -> String {
v >= 1_000_000 ? String(format: "%.2f млн ₽", v / 1_000_000) : String(format: "%.0f ₽", v)
}
}
// MARK: - EditSavingsTransactionView
struct EditSavingsTransactionView: View {
@Binding var isPresented: Bool
@EnvironmentObject var authManager: AuthManager
let transaction: SavingsTransaction
let categories: [SavingsCategory]
let onSaved: () async -> Void
@State private var amount: String
@State private var description: String
@State private var type: String
@State private var selectedCategoryId: Int?
@State private var date: Date
@State private var isLoading = false
@State private var errorMessage: String?
var isDeposit: Bool { type == "deposit" }
init(isPresented: Binding<Bool>, transaction: SavingsTransaction, categories: [SavingsCategory], onSaved: @escaping () async -> Void) {
self._isPresented = isPresented
self.transaction = transaction
self.categories = categories
self.onSaved = onSaved
self._amount = State(initialValue: String(format: "%.0f", transaction.amount))
self._description = State(initialValue: transaction.description ?? "")
self._type = State(initialValue: transaction.type)
self._selectedCategoryId = State(initialValue: transaction.categoryId)
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
let d = transaction.date.flatMap { df.date(from: String($0.prefix(10))) } ?? Date()
self._date = State(initialValue: d)
}
var body: some View {
ZStack {
Color(hex: "06060f").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 ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) }
}.disabled(amount.isEmpty || isLoading)
}
.padding(.horizontal, 20).padding(.vertical, 16)
Divider().background(Color.white.opacity(0.1))
ScrollView {
VStack(spacing: 20) {
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: "calendar").font(.caption).foregroundColor(Color(hex: "8888aa"))
DatePicker("", selection: $date, displayedComponents: .date)
.labelsHidden().colorInvert().colorMultiply(Color(hex: "0D9488"))
}
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)))
}
if let err = errorMessage {
Text(err)
.font(.caption).foregroundColor(Color(hex: "ff4757"))
.padding(10)
.frame(maxWidth: .infinity)
.background(RoundedRectangle(cornerRadius: 10).fill(Color(hex: "ff4757").opacity(0.1)))
}
}.padding(20)
}
}
}
}
func save() {
guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")),
let cid = selectedCategoryId else { return }
isLoading = true
errorMessage = nil
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
let dateStr = df.string(from: date)
Task {
do {
let req = CreateSavingsTransactionRequest(categoryId: cid, amount: a, type: type,
description: description.isEmpty ? nil : description, date: dateStr)
try await APIService.shared.updateSavingsTransaction(token: authManager.token, id: transaction.id, request: req)
await onSaved()
await MainActor.run { isPresented = false }
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
}
}
// MARK: - RecurringPlansView
struct RecurringPlansView: View {
@EnvironmentObject var authManager: AuthManager
let category: SavingsCategory
@State private var plans: [SavingsRecurringPlan] = []
@State private var isLoading = true
@State private var showAdd = false
@State private var newAmount = ""
@State private var newDay = "1"
@State private var newEffective = Date()
@State private var editingPlan: SavingsRecurringPlan?
@State private var editAmount = ""
@State private var editDay = ""
var body: some View {
ZStack {
Color(hex: "06060f").ignoresSafeArea()
VStack(spacing: 0) {
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
HStack {
Spacer()
Text("Регулярные платежи").font(.headline).foregroundColor(.white)
Spacer()
}
.padding(.horizontal, 20).padding(.vertical, 16)
Divider().background(Color.white.opacity(0.1))
ScrollView {
VStack(spacing: 12) {
if isLoading {
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 20)
} else if plans.isEmpty && !showAdd {
Text("Нет регулярных платежей").font(.callout).foregroundColor(Color(hex: "8888aa"))
.padding(.top, 20)
} else {
ForEach(plans) { plan in
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(formatAmt(plan.amount)).font(.callout.bold()).foregroundColor(.white)
if let day = plan.day {
Text("Каждый \(day) день месяца").font(.caption).foregroundColor(Color(hex: "8888aa"))
}
if let eff = plan.effective {
Text("С \(formatDate(eff))").font(.caption2).foregroundColor(Color(hex: "8888aa"))
}
}
Spacer()
Button(action: {
editingPlan = plan
editAmount = String(format: "%.0f", plan.amount)
editDay = String(plan.day ?? 1)
}) {
Image(systemName: "pencil").foregroundColor(Color(hex: "8888aa"))
}
Button(action: { Task { await deletePlan(plan) } }) {
Image(systemName: "trash").foregroundColor(Color(hex: "ff4757").opacity(0.7))
}
}
.padding(14)
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.05)))
.padding(.horizontal)
}
}
if let plan = editingPlan {
VStack(spacing: 10) {
Text("Редактировать платёж").font(.caption.bold()).foregroundColor(Color(hex: "8888aa"))
HStack {
TextField("Сумма", text: $editAmount).keyboardType(.decimalPad)
.foregroundColor(.white).padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07)))
Text("").foregroundColor(Color(hex: "8888aa"))
TextField("День", text: $editDay).keyboardType(.numberPad)
.foregroundColor(.white).frame(width: 60).padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07)))
}
HStack {
Button("Отмена") { editingPlan = nil }.foregroundColor(Color(hex: "8888aa"))
Spacer()
Button("Сохранить") { Task { await updatePlan(plan) } }
.foregroundColor(Color(hex: "0D9488")).fontWeight(.semibold)
}
}
.padding(14)
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.07)))
.padding(.horizontal)
}
if showAdd {
VStack(spacing: 10) {
Text("Новый платёж").font(.caption.bold()).foregroundColor(Color(hex: "8888aa"))
HStack {
TextField("Сумма", text: $newAmount).keyboardType(.decimalPad)
.foregroundColor(.white).padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07)))
Text("").foregroundColor(Color(hex: "8888aa"))
TextField("День", text: $newDay).keyboardType(.numberPad)
.foregroundColor(.white).frame(width: 60).padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07)))
}
HStack {
Text("Начало").font(.caption).foregroundColor(Color(hex: "8888aa"))
DatePicker("", selection: $newEffective, displayedComponents: .date)
.labelsHidden().colorInvert().colorMultiply(Color(hex: "0D9488"))
}
HStack {
Button("Отмена") { showAdd = false }.foregroundColor(Color(hex: "8888aa"))
Spacer()
Button("Добавить") { Task { await addPlan() } }
.foregroundColor(Color(hex: "0D9488")).fontWeight(.semibold)
}
}
.padding(14)
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.07)))
.padding(.horizontal)
}
if !showAdd && editingPlan == nil {
Button(action: { showAdd = true }) {
Label("Добавить платёж", systemImage: "plus.circle")
.foregroundColor(Color(hex: "0D9488"))
.padding(14)
.frame(maxWidth: .infinity)
.background(RoundedRectangle(cornerRadius: 14).fill(Color(hex: "0D9488").opacity(0.08)))
}
.padding(.horizontal)
}
Spacer(minLength: 40)
}
.padding(.top, 12)
}
}
}
.task { await load() }
}
func load() async {
isLoading = true
plans = (try? await APIService.shared.getRecurringPlans(token: authManager.token, categoryId: category.id)) ?? []
isLoading = false
}
func addPlan() async {
guard let a = Double(newAmount.replacingOccurrences(of: ",", with: ".")) else { return }
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
let effStr = df.string(from: newEffective)
let req = CreateRecurringPlanRequest(effective: effStr, amount: a, day: Int(newDay))
if let plan = try? await APIService.shared.createRecurringPlan(token: authManager.token, categoryId: category.id, request: req) {
await MainActor.run { plans.append(plan); showAdd = false; newAmount = ""; newDay = "1" }
}
}
func updatePlan(_ plan: SavingsRecurringPlan) async {
guard let a = Double(editAmount.replacingOccurrences(of: ",", with: ".")) else { return }
let req = UpdateRecurringPlanRequest(effective: plan.effective, amount: a, day: Int(editDay))
if let updated = try? await APIService.shared.updateRecurringPlan(token: authManager.token, planId: plan.id, request: req) {
await MainActor.run {
if let idx = plans.firstIndex(where: { $0.id == plan.id }) { plans[idx] = updated }
editingPlan = nil
}
}
}
func deletePlan(_ plan: SavingsRecurringPlan) async {
try? await APIService.shared.deleteRecurringPlan(token: authManager.token, planId: plan.id)
await MainActor.run { plans.removeAll { $0.id == plan.id } }
}
func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) }
func formatDate(_ s: String) -> String {
let parts = s.prefix(10).split(separator: "-")
guard parts.count == 3 else { return String(s.prefix(10)) }
return "\(parts[2]).\(parts[1]).\(parts[0])"
}
}