Files
pulse-mobile/PulseHealth/Views/Finance/FinanceView.swift
Cosmo e7af51af10 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
2026-03-25 17:14:59 +00:00

484 lines
22 KiB
Swift
Raw 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: "0a0a1a").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)
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.bottom, 12)
switch selectedTab {
case 0: FinanceOverviewTab(month: selectedMonth, year: selectedYear)
case 1: FinanceTransactionsTab(month: selectedMonth, year: selectedYear)
default: FinanceAnalyticsTab(month: selectedMonth, year: selectedYear)
}
}
}
}
func monthLabel() -> String {
let df = DateFormatter()
df.dateFormat = "LLLL yyyy"
df.locale = Locale(identifier: "ru_RU")
var comps = DateComponents(); comps.month = selectedMonth; comps.year = selectedYear
if let d = Calendar.current.date(from: comps) { return df.string(from: d) }
return "\(selectedMonth)/\(selectedYear)"
}
func prevMonth() {
if selectedMonth == 1 { selectedMonth = 12; selectedYear -= 1 }
else { selectedMonth -= 1 }
}
func nextMonth() {
if selectedMonth == 12 { selectedMonth = 1; selectedYear += 1 }
else { selectedMonth += 1 }
}
}
// MARK: - FinanceOverviewTab
struct FinanceOverviewTab: View {
@EnvironmentObject var authManager: AuthManager
let month: Int
let year: Int
@State private var summary: FinanceSummary?
@State private var categories: [FinanceCategory] = []
@State private var isLoading = true
var expenseByCategory: [CategorySpend] {
(summary?.byCategory ?? []).filter { ($0.total ?? 0) > 0 }.sorted { ($0.total ?? 0) > ($1.total ?? 0) }
}
var dailyPoints: [DailySpend] { summary?.daily ?? [] }
var body: some View {
ScrollView {
VStack(spacing: 16) {
if isLoading {
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.icon ?? "💸").font(.subheadline)
Text(cat.categoryName ?? "").font(.callout).foregroundColor(.white)
Spacer()
Text(formatAmt(cat.total ?? 0)).font(.callout.bold()).foregroundColor(Color(hex: "ff4757"))
Text("\(Int(pct * 100))%").font(.caption2).foregroundColor(Color(hex: "8888aa"))
}
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.07))
RoundedRectangle(cornerRadius: 3)
.fill(Color(hex: "ff4757").opacity(0.7))
.frame(width: geo.size.width * CGFloat(pct))
}
}
.frame(height: 5)
}
}
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
.padding(.horizontal)
}
// Pie Chart
if expenseByCategory.count > 1 {
VStack(alignment: .leading, spacing: 8) {
Text("Расходы по категориям").font(.subheadline.bold()).foregroundColor(.white)
Chart(expenseByCategory) { cat in
SectorMark(
angle: .value("Сумма", cat.total ?? 0),
innerRadius: .ratio(0.55),
angularInset: 2
)
.foregroundStyle(by: .value("Кат.", cat.categoryName ?? ""))
.cornerRadius(4)
}
.frame(height: 200)
.chartForegroundStyleScale(range: Gradient(colors: [
Color(hex: "0D9488"), Color(hex: "6366f1"), Color(hex: "f59e0b"),
Color(hex: "ec4899"), Color(hex: "14b8a6"), Color(hex: "8b5cf6")
]))
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
.padding(.horizontal)
}
// Daily Line Chart
if !dailyPoints.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Ежедневные траты").font(.subheadline.bold()).foregroundColor(.white)
let df: DateFormatter = {
let d = DateFormatter(); d.dateFormat = "yyyy-MM-dd"; return d
}()
Chart(dailyPoints.compactMap { p -> (Date, Double)? in
guard let d = df.date(from: p.date) else { return nil }
return (d, p.expense ?? p.total ?? 0)
}, id: \.0) { item in
AreaMark(x: .value("День", item.0), y: .value("Сумма", item.1))
.foregroundStyle(LinearGradient(colors: [Color(hex: "ff4757").opacity(0.4), Color.clear], startPoint: .top, endPoint: .bottom))
LineMark(x: .value("День", item.0), y: .value("Сумма", item.1))
.foregroundStyle(Color(hex: "ff4757"))
.lineStyle(StrokeStyle(lineWidth: 2))
}
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: 5)) { _ in
AxisValueLabel(format: .dateTime.day()).foregroundStyle(Color(hex: "8888aa"))
AxisGridLine().foregroundStyle(Color.white.opacity(0.05))
}
}
.chartYAxis {
AxisMarks { v in
AxisGridLine().foregroundStyle(Color.white.opacity(0.05))
AxisValueLabel().foregroundStyle(Color(hex: "8888aa"))
}
}
.frame(height: 140)
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
.padding(.horizontal)
}
}
Spacer(minLength: 80)
}
.padding(.top, 8)
}
.task { await load() }
.onChange(of: month) { _ in Task { await load() } }
.onChange(of: year) { _ in Task { await load() } }
.refreshable { await load(refresh: true) }
}
func load(refresh: Bool = false) async {
if !refresh { isLoading = true }
async let s = APIService.shared.getFinanceSummary(token: authManager.token, month: month, year: year)
async let c = APIService.shared.getFinanceCategories(token: authManager.token)
summary = try? await s
categories = (try? await c) ?? []
isLoading = false
}
func formatAmt(_ v: Double) -> String {
v >= 1000 ? String(format: "%.0f ₽", v) : String(format: "%.0f ₽", v)
}
}
// MARK: - FinanceSummaryCard2
struct FinanceSummaryCard2: View {
let summary: FinanceSummary
var body: some View {
VStack(spacing: 16) {
VStack(spacing: 4) {
Text("Баланс месяца").font(.subheadline).foregroundColor(Color(hex: "8888aa"))
Text(formatAmt(summary.balance ?? 0))
.font(.system(size: 34, weight: .bold))
.foregroundColor((summary.balance ?? 0) >= 0 ? Color(hex: "0D9488") : Color(hex: "ff4757"))
}
HStack {
VStack(spacing: 4) {
Text("Доходы").font(.caption).foregroundColor(Color(hex: "8888aa"))
Text("+\(formatAmt(summary.totalIncome ?? 0))")
.font(.callout.bold()).foregroundColor(Color(hex: "0D9488"))
}
Spacer()
VStack(spacing: 4) {
Text("Расходы").font(.caption).foregroundColor(Color(hex: "8888aa"))
Text("-\(formatAmt(summary.totalExpense ?? 0))")
.font(.callout.bold()).foregroundColor(Color(hex: "ff4757"))
}
Spacer()
VStack(spacing: 4) {
Text("Перенос").font(.caption).foregroundColor(Color(hex: "8888aa"))
Text("\(formatAmt(summary.carriedOver ?? 0))")
.font(.callout.bold()).foregroundColor(.white)
}
}
}
.padding(20)
.background(
LinearGradient(colors: [Color(hex: "1a1a3e"), Color(hex: "12122a")], startPoint: .topLeading, endPoint: .bottomTrailing)
)
.cornerRadius(20)
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "0D9488").opacity(0.3), lineWidth: 1))
.padding(.horizontal)
}
func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) }
}
// MARK: - FinanceTransactionsTab
struct FinanceTransactionsTab: View {
@EnvironmentObject var authManager: AuthManager
let month: Int
let year: Int
@State private var transactions: [FinanceTransaction] = []
@State private var categories: [FinanceCategory] = []
@State private var isLoading = true
@State private var showAdd = false
var groupedByDay: [(key: String, value: [FinanceTransaction])] {
let grouped = Dictionary(grouping: transactions) { $0.dateOnly }
return grouped.sorted { $0.key > $1.key }
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
Group {
if isLoading {
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
Spacer()
} else if transactions.isEmpty {
VStack { EmptyState(icon: "creditcard", text: "Нет транзакций"); Spacer() }
} else {
List {
ForEach(groupedByDay, id: \.key) { section in
Section(header:
Text(formatSectionDate(section.key))
.font(.caption).foregroundColor(Color(hex: "8888aa"))
) {
ForEach(section.value) { tx in
FinanceTxRow(transaction: tx, categories: categories)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
.onDelete { idx in
let toDelete = idx.map { section.value[$0] }
Task {
for tx in toDelete { try? await APIService.shared.deleteTransaction(token: authManager.token, id: tx.id) }
await load(refresh: true)
}
}
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
.refreshable { await load(refresh: true) }
Button(action: { showAdd = true }) {
ZStack {
Circle()
.fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 56, height: 56)
.shadow(color: Color(hex: "0D9488").opacity(0.4), radius: 8, y: 4)
Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white)
}
}
.padding(.bottom, 90)
.padding(.trailing, 20)
}
.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) {
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) { _ 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) }
}