Files
pulse-mobile/PulseHealth/Views/Finance/FinanceView.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

840 lines
40 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
import Charts
// MARK: - FinanceView
struct FinanceView: View {
@EnvironmentObject var authManager: AuthManager
@State private var selectedTab = 0
@State private var selectedMonth = Calendar.current.component(.month, from: Date())
@State private var selectedYear = Calendar.current.component(.year, from: Date())
var body: some View {
ZStack {
Color(hex: "06060f").ignoresSafeArea()
VStack(spacing: 0) {
// Header with month picker
HStack {
Text("Финансы").font(.title.bold()).foregroundColor(.white)
Spacer()
HStack(spacing: 8) {
Button(action: { prevMonth() }) {
Image(systemName: "chevron.left").foregroundColor(Color(hex: "8888aa"))
}
Text(monthLabel())
.font(.subheadline).foregroundColor(.white)
.frame(minWidth: 80)
Button(action: { nextMonth() }) {
Image(systemName: "chevron.right").foregroundColor(Color(hex: "8888aa"))
}
}
}
.padding(.horizontal)
.padding(.top, 16)
.padding(.bottom, 12)
Picker("", selection: $selectedTab) {
Text("Обзор").tag(0)
Text("Транзакции").tag(1)
Text("Аналитика").tag(2)
Text("Категории").tag(3)
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.bottom, 12)
switch selectedTab {
case 0: FinanceOverviewTab(month: selectedMonth, year: selectedYear)
case 1: FinanceTransactionsTab(month: selectedMonth, year: selectedYear)
case 2: FinanceAnalyticsTab(month: selectedMonth, year: selectedYear)
default: FinanceCategoriesTab()
}
}
}
}
func monthLabel() -> String {
let df = DateFormatter()
df.dateFormat = "LLLL yyyy"
df.locale = Locale(identifier: "ru_RU")
var comps = DateComponents(); comps.month = selectedMonth; comps.year = selectedYear
if let d = Calendar.current.date(from: comps) { return df.string(from: d) }
return "\(selectedMonth)/\(selectedYear)"
}
func prevMonth() {
if selectedMonth == 1 { selectedMonth = 12; selectedYear -= 1 }
else { selectedMonth -= 1 }
}
func nextMonth() {
if selectedMonth == 12 { selectedMonth = 1; selectedYear += 1 }
else { selectedMonth += 1 }
}
}
// MARK: - FinanceOverviewTab
struct FinanceOverviewTab: View {
@EnvironmentObject var authManager: AuthManager
let month: Int
let year: Int
@State private var summary: FinanceSummary?
@State private var categories: [FinanceCategory] = []
@State private var isLoading = true
var expenseByCategory: [CategorySpend] {
(summary?.byCategory ?? []).filter { ($0.total ?? 0) > 0 }.sorted { ($0.total ?? 0) > ($1.total ?? 0) }
}
var dailyPoints: [DailySpend] { summary?.daily ?? [] }
var body: some View {
ScrollView {
VStack(spacing: 16) {
if isLoading {
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
} 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.emoji ?? "💸").font(.subheadline)
Text(cat.categoryName ?? "").font(.callout).foregroundColor(.white)
Spacer()
Text(formatAmt(cat.total ?? 0)).font(.callout.bold()).foregroundColor(Color(hex: "ff4757"))
Text("\(Int(pct * 100))%").font(.caption2).foregroundColor(Color(hex: "8888aa"))
}
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.07))
RoundedRectangle(cornerRadius: 3)
.fill(Color(hex: "ff4757").opacity(0.7))
.frame(width: geo.size.width * CGFloat(pct))
}
}
.frame(height: 5)
}
}
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
.padding(.horizontal)
}
// Pie Chart
if expenseByCategory.count > 1 {
VStack(alignment: .leading, spacing: 8) {
Text("Расходы по категориям").font(.subheadline.bold()).foregroundColor(.white)
Chart(expenseByCategory) { cat in
SectorMark(
angle: .value("Сумма", cat.total ?? 0),
innerRadius: .ratio(0.55),
angularInset: 2
)
.foregroundStyle(by: .value("Кат.", cat.categoryName ?? ""))
.cornerRadius(4)
}
.frame(height: 200)
.chartForegroundStyleScale(range: Gradient(colors: [
Color(hex: "0D9488"), Color(hex: "6366f1"), Color(hex: "f59e0b"),
Color(hex: "ec4899"), Color(hex: "14b8a6"), Color(hex: "8b5cf6")
]))
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
.padding(.horizontal)
}
// Daily Line Chart
if !dailyPoints.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Ежедневные траты").font(.subheadline.bold()).foregroundColor(.white)
let df: DateFormatter = {
let d = DateFormatter(); d.dateFormat = "yyyy-MM-dd"; return d
}()
Chart(dailyPoints.compactMap { p -> (Date, Double)? in
guard let d = df.date(from: p.date) else { return nil }
return (d, p.expense ?? p.total ?? 0)
}, id: \.0) { item in
AreaMark(x: .value("День", item.0), y: .value("Сумма", item.1))
.foregroundStyle(LinearGradient(colors: [Color(hex: "ff4757").opacity(0.4), Color.clear], startPoint: .top, endPoint: .bottom))
LineMark(x: .value("День", item.0), y: .value("Сумма", item.1))
.foregroundStyle(Color(hex: "ff4757"))
.lineStyle(StrokeStyle(lineWidth: 2))
}
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: 5)) { _ in
AxisValueLabel(format: .dateTime.day()).foregroundStyle(Color(hex: "8888aa"))
AxisGridLine().foregroundStyle(Color.white.opacity(0.05))
}
}
.chartYAxis {
AxisMarks { v in
AxisGridLine().foregroundStyle(Color.white.opacity(0.05))
AxisValueLabel().foregroundStyle(Color(hex: "8888aa"))
}
}
.frame(height: 140)
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
.padding(.horizontal)
}
}
Spacer(minLength: 80)
}
.padding(.top, 8)
}
.task { await load() }
.onChange(of: month) { Task { await load() } }
.onChange(of: year) { Task { await load() } }
.refreshable { await load(refresh: true) }
}
func load(refresh: Bool = false) async {
if !refresh { isLoading = true }
async let s = APIService.shared.getFinanceSummary(token: authManager.token, month: month, year: year)
async let c = APIService.shared.getFinanceCategories(token: authManager.token)
summary = try? await s
categories = (try? await c) ?? []
isLoading = false
}
func formatAmt(_ v: Double) -> String {
v >= 1000 ? String(format: "%.0f ₽", v) : String(format: "%.0f ₽", v)
}
}
// MARK: - FinanceSummaryCard2
struct FinanceSummaryCard2: View {
let summary: FinanceSummary
var body: some View {
VStack(spacing: 16) {
VStack(spacing: 4) {
Text("Баланс месяца").font(.subheadline).foregroundColor(Color(hex: "8888aa"))
Text(formatAmt(summary.balance ?? 0))
.font(.system(size: 34, weight: .bold))
.foregroundColor((summary.balance ?? 0) >= 0 ? Color(hex: "0D9488") : Color(hex: "ff4757"))
}
HStack {
VStack(spacing: 4) {
Text("Доходы").font(.caption).foregroundColor(Color(hex: "8888aa"))
Text("+\(formatAmt(summary.totalIncome ?? 0))")
.font(.callout.bold()).foregroundColor(Color(hex: "0D9488"))
}
Spacer()
VStack(spacing: 4) {
Text("Расходы").font(.caption).foregroundColor(Color(hex: "8888aa"))
Text("-\(formatAmt(summary.totalExpense ?? 0))")
.font(.callout.bold()).foregroundColor(Color(hex: "ff4757"))
}
Spacer()
VStack(spacing: 4) {
Text("Перенос").font(.caption).foregroundColor(Color(hex: "8888aa"))
Text("\(formatAmt(summary.carriedOver ?? 0))")
.font(.callout.bold()).foregroundColor(.white)
}
}
}
.padding(20)
.background(
LinearGradient(colors: [Color(hex: "1a1a3e"), Color(hex: "12122a")], startPoint: .topLeading, endPoint: .bottomTrailing)
)
.cornerRadius(20)
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "0D9488").opacity(0.3), lineWidth: 1))
.padding(.horizontal)
}
func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) }
}
// MARK: - FinanceTransactionsTab
struct FinanceTransactionsTab: View {
@EnvironmentObject var authManager: AuthManager
let month: Int
let year: Int
@State private var transactions: [FinanceTransaction] = []
@State private var categories: [FinanceCategory] = []
@State private var isLoading = true
@State private var showAdd = false
@State private var editingTransaction: FinanceTransaction?
var groupedByDay: [(key: String, value: [FinanceTransaction])] {
let grouped = Dictionary(grouping: transactions) { $0.dateOnly }
return grouped.sorted { $0.key > $1.key }
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
Group {
if isLoading {
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
Spacer()
} else if transactions.isEmpty {
VStack { EmptyState(icon: "creditcard", text: "Нет транзакций"); Spacer() }
} else {
List {
ForEach(groupedByDay, id: \.key) { section in
Section(header:
Text(formatSectionDate(section.key))
.font(.caption).foregroundColor(Color(hex: "8888aa"))
) {
ForEach(section.value) { tx in
FinanceTxRow(transaction: tx, categories: categories)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.onTapGesture { editingTransaction = tx }
}
.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) { Task { await load() } }
.onChange(of: year) { Task { await load() } }
.sheet(isPresented: $showAdd) {
AddTransactionView(isPresented: $showAdd, categories: categories) { await load(refresh: true) }
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationBackground(Color(hex: "06060f"))
}
.sheet(item: $editingTransaction) { tx in
EditTransactionView(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 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?.emoji ?? (isIncome ? "💰" : "💸")).font(.title3)
}
VStack(alignment: .leading, spacing: 2) {
Text(transaction.description ?? cat?.name ?? "Операция")
.font(.callout).foregroundColor(.white)
Text(transaction.dateFormatted).font(.caption2).foregroundColor(Color(hex: "8888aa"))
}
Spacer()
Text("\(isIncome ? "+" : "-")\(formatAmt(transaction.amount))")
.font(.callout.bold())
.foregroundColor(isIncome ? 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 { 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) { Task { await load() } }
.onChange(of: year) { 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) }
}
// MARK: - EditTransactionView
struct EditTransactionView: View {
@Binding var isPresented: Bool
@EnvironmentObject var authManager: AuthManager
let transaction: FinanceTransaction
let categories: [FinanceCategory]
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
var filteredCategories: [FinanceCategory] { categories.filter { $0.type == type } }
var isExpense: Bool { type == "expense" }
init(isPresented: Binding<Bool>, transaction: FinanceTransaction, categories: [FinanceCategory], 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: "00d4aa")).scaleEffect(0.8) }
else { Text("Сохранить").foregroundColor(amount.isEmpty ? Color(hex: "8888aa") : Color(hex: "00d4aa")).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 = "expense" }) {
Text("Расход").font(.callout.bold())
.foregroundColor(isExpense ? .black : Color(hex: "ff4757"))
.frame(maxWidth: .infinity).padding(.vertical, 12)
.background(isExpense ? Color(hex: "ff4757") : Color.clear)
}
Button(action: { type = "income" }) {
Text("Доход").font(.callout.bold())
.foregroundColor(!isExpense ? .black : Color(hex: "00d4aa"))
.frame(maxWidth: .infinity).padding(.vertical, 12)
.background(!isExpense ? Color(hex: "00d4aa") : Color.clear)
}
}
.background(Color.white.opacity(0.07)).cornerRadius(12)
HStack {
Text(isExpense ? "" : "+").font(.title.bold())
.foregroundColor(isExpense ? Color(hex: "ff4757") : Color(hex: "00d4aa"))
TextField("0", text: $amount).keyboardType(.decimalPad)
.font(.system(size: 36, 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: "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)))
}
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"))
}
if !filteredCategories.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Label("Категория", systemImage: "tag.fill").font(.caption).foregroundColor(Color(hex: "8888aa"))
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 8) {
ForEach(filteredCategories) { cat in
Button(action: { selectedCategoryId = selectedCategoryId == cat.id ? nil : cat.id }) {
HStack(spacing: 6) {
Text(cat.emoji ?? "").font(.callout)
Text(cat.name).font(.caption).lineLimit(1)
}
.foregroundColor(selectedCategoryId == cat.id ? .black : .white)
.padding(.horizontal, 10).padding(.vertical, 8)
.frame(maxWidth: .infinity)
.background(RoundedRectangle(cornerRadius: 10)
.fill(selectedCategoryId == cat.id ? Color(hex: "00d4aa") : Color.white.opacity(0.07)))
}
}
}
}
}
}.padding(20)
}
}
}
}
func save() {
guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")) else { return }
isLoading = true
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
let dateStr = df.string(from: date)
Task {
let req = CreateTransactionRequest(amount: a, categoryId: selectedCategoryId,
description: description.isEmpty ? nil : description,
type: type, date: dateStr)
try? await APIService.shared.updateTransaction(token: authManager.token, id: transaction.id, request: req)
await onSaved()
await MainActor.run { isPresented = false }
}
}
}
// MARK: - FinanceCategoriesTab
struct FinanceCategoriesTab: View {
@EnvironmentObject var authManager: AuthManager
@State private var categories: [FinanceCategory] = []
@State private var isLoading = true
@State private var editingCategory: FinanceCategory?
@State private var showAdd = false
@State private var selectedType = "expense"
var filtered: [FinanceCategory] { categories.filter { $0.type == selectedType } }
var body: some View {
ZStack(alignment: .bottomTrailing) {
ScrollView {
VStack(spacing: 12) {
HStack(spacing: 0) {
Button(action: { selectedType = "expense" }) {
Text("Расходы").font(.callout.bold())
.foregroundColor(selectedType == "expense" ? .black : Color(hex: "ff4757"))
.frame(maxWidth: .infinity).padding(.vertical, 10)
.background(selectedType == "expense" ? Color(hex: "ff4757") : Color.clear)
}
Button(action: { selectedType = "income" }) {
Text("Доходы").font(.callout.bold())
.foregroundColor(selectedType == "income" ? .black : Color(hex: "0D9488"))
.frame(maxWidth: .infinity).padding(.vertical, 10)
.background(selectedType == "income" ? Color(hex: "0D9488") : Color.clear)
}
}
.background(Color.white.opacity(0.07)).cornerRadius(12)
.padding(.horizontal)
if isLoading {
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
} else if filtered.isEmpty {
EmptyState(icon: "tag", text: "Нет категорий")
} else {
ForEach(filtered) { cat in
HStack(spacing: 12) {
ZStack {
Circle().fill(Color(hex: selectedType == "expense" ? "ff4757" : "0D9488").opacity(0.15))
.frame(width: 40, height: 40)
Text(cat.emoji ?? (selectedType == "expense" ? "💸" : "💰")).font(.title3)
}
Text(cat.name).font(.callout).foregroundColor(.white)
Spacer()
Button(action: { editingCategory = cat }) {
Image(systemName: "pencil").foregroundColor(Color(hex: "8888aa"))
}
Button(action: { Task { await deleteCategory(cat) } }) {
Image(systemName: "trash").foregroundColor(Color(hex: "ff4757").opacity(0.7))
}
}
.padding(14)
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.05)))
.padding(.horizontal)
}
}
Spacer(minLength: 80)
}
.padding(.top, 8)
}
.task { await load() }
.refreshable { await load(refresh: true) }
Button(action: { showAdd = true }) {
ZStack {
Circle()
.fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 56, height: 56)
.shadow(color: Color(hex: "0D9488").opacity(0.4), radius: 8, y: 4)
Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white)
}
}
.padding(.bottom, 90).padding(.trailing, 20)
}
.sheet(isPresented: $showAdd) {
FinanceCategoryFormView(isPresented: $showAdd, category: nil, defaultType: selectedType) { await load(refresh: true) }
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
.presentationBackground(Color(hex: "06060f"))
}
.sheet(item: $editingCategory) { cat in
FinanceCategoryFormView(isPresented: .constant(true), category: cat, defaultType: selectedType) {
editingCategory = nil
await load(refresh: true)
}
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
.presentationBackground(Color(hex: "06060f"))
}
}
func load(refresh: Bool = false) async {
if !refresh { isLoading = true }
categories = (try? await APIService.shared.getFinanceCategories(token: authManager.token)) ?? []
isLoading = false
}
func deleteCategory(_ cat: FinanceCategory) async {
try? await APIService.shared.deleteFinanceCategory(token: authManager.token, id: cat.id)
await load(refresh: true)
}
}
// MARK: - FinanceCategoryFormView
struct FinanceCategoryFormView: View {
@Binding var isPresented: Bool
@EnvironmentObject var authManager: AuthManager
let category: FinanceCategory?
let defaultType: String
let onSaved: () async -> Void
@State private var name = ""
@State private var type: String
@State private var emoji = ""
@State private var isLoading = false
let emojis = ["💸","💰","🏠","🍔","🚗","🎓","💊","✈️","👗","🎮","📱","🛒","","🐾","🎵","💄","🍺","🎁","🏋️","📚"]
init(isPresented: Binding<Bool>, category: FinanceCategory?, defaultType: String, onSaved: @escaping () async -> Void) {
self._isPresented = isPresented
self.category = category
self.defaultType = defaultType
self.onSaved = onSaved
self._name = State(initialValue: category?.name ?? "")
self._type = State(initialValue: category?.type ?? defaultType)
self._emoji = State(initialValue: category?.emoji ?? "")
}
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(category == nil ? "Новая категория" : "Редактировать").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) {
HStack(spacing: 0) {
Button(action: { type = "expense" }) {
Text("Расход").font(.callout.bold())
.foregroundColor(type == "expense" ? .black : Color(hex: "ff4757"))
.frame(maxWidth: .infinity).padding(.vertical, 10)
.background(type == "expense" ? Color(hex: "ff4757") : Color.clear)
}
Button(action: { type = "income" }) {
Text("Доход").font(.callout.bold())
.foregroundColor(type == "income" ? .black : Color(hex: "0D9488"))
.frame(maxWidth: .infinity).padding(.vertical, 10)
.background(type == "income" ? Color(hex: "0D9488") : Color.clear)
}
}
.background(Color.white.opacity(0.07)).cornerRadius(12)
VStack(alignment: .leading, spacing: 8) {
Label("Название", systemImage: "pencil").font(.caption).foregroundColor(Color(hex: "8888aa"))
TextField("Название категории", text: $name)
.foregroundColor(.white).padding(14)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
}
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(emojis, id: \.self) { e in
Button(action: { emoji = e }) {
Text(e).font(.title3)
.frame(width: 44, height: 44)
.background(Circle().fill(emoji == e ? Color(hex: "0D9488").opacity(0.25) : Color.white.opacity(0.05)))
.overlay(Circle().stroke(emoji == e ? Color(hex: "0D9488") : Color.clear, lineWidth: 2))
}
}
}
}
}.padding(20)
}
}
}
}
func save() {
isLoading = true
Task {
let req = CreateFinanceCategoryRequest(name: name, type: type, emoji: emoji.isEmpty ? nil : emoji, budget: nil)
if let cat = category {
try? await APIService.shared.updateFinanceCategory(token: authManager.token, id: cat.id, request: req)
} else {
try? await APIService.shared.createFinanceCategory(token: authManager.token, request: req)
}
await onSaved()
await MainActor.run { isPresented = false }
}
}
}