Improve transaction UI, add budget progress and spending analytics

This commit is contained in:
“SamoilenkoVadym” 2025-03-02 22:52:10 +00:00
parent 148f357aa2
commit d7d8de38f1
3 changed files with 194 additions and 76 deletions

View file

@ -1,15 +1,58 @@
import SwiftUI
struct BudgetProgress: View {
let spent: Double
let total: Double
let color: Color
private var percentage: Double {
guard total > 0 else { return 0 }
return min(spent / total, 1.0)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle()
.fill(Color(uiColor: .systemFill))
.frame(height: 6)
.cornerRadius(3)
Rectangle()
.fill(color)
.frame(width: geometry.size.width * CGFloat(percentage), height: 6)
.cornerRadius(3)
}
}
.frame(height: 6)
HStack {
Text("\(Int(percentage * 100))% spent")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text("\(spent.formatAsCurrency()) of \(total.formatAsCurrency())")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
struct DashboardView: View {
@ObservedObject var store: TransactionsStore
@ObservedObject var accountsStore: AccountsStore
@EnvironmentObject private var settings: AppSettings
@State private var showingAddAccount = false
@State private var selectedAccount: AccountModel?
@AppStorage("includeCreditCards") private var includeCreditCards = false
private var totalAccountsBalance: Double {
private var adjustedBalance: Double {
accountsStore.accounts.reduce(0) { total, account in
total + (account.type == .creditCard ? 0 : account.balance)
total + ((!includeCreditCards && account.type == .creditCard) ? 0 : account.balance)
}
}
@ -32,16 +75,43 @@ struct DashboardView: View {
.reduce(0) { $0 + $1.amountInCurrentCurrency() }
}
private var categoryExpenses: [(category: CategoryModel, amount: Double, percentage: Double)] {
let expensesByCategory = Dictionary(grouping: thisMonthTransactions.filter { $0.isExpense }) {
$0.category
}
let totalExpenses = monthlyExpenses
return expensesByCategory.map { (category, transactions) in
let categoryModel = CategoryModel.category(for: category)
let amount = transactions.reduce(0) { $0 + $1.amountInCurrentCurrency() }
let percentage = totalExpenses > 0 ? amount / totalExpenses : 0
return (categoryModel, amount, percentage)
}
.sorted { $0.amount > $1.amount }
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Monthly Summary
VStack(spacing: 8) {
Text("This Month")
.font(.subheadline)
.foregroundColor(Color(uiColor: .secondaryLabel))
HStack {
Text("This Month")
.font(.subheadline)
.foregroundColor(Color(uiColor: .secondaryLabel))
Spacer()
Menu {
Toggle("Include Credit Cards", isOn: $includeCreditCards)
} label: {
Image(systemName: "ellipsis.circle")
.foregroundColor(.secondary)
}
}
Text(totalAccountsBalance.formatAsCurrency())
Text(adjustedBalance.formatAsCurrency())
.font(.system(size: 34, weight: .bold))
HStack(spacing: 20) {
@ -97,11 +167,76 @@ struct DashboardView: View {
.padding(.horizontal)
// Spending Analysis Section
if !monthlyExpenses.isZero {
if !categoryExpenses.isEmpty {
VStack(alignment: .leading, spacing: 16) {
Text("Spending Analysis")
.font(.title2)
.fontWeight(.bold)
HStack {
Text("Spending Analysis")
.font(.title2)
.fontWeight(.bold)
Spacer()
Text("Monthly Budget")
.font(.subheadline)
.foregroundColor(.secondary)
}
BudgetProgress(
spent: monthlyExpenses,
total: 2000, // В будущем можно добавить настраиваемый бюджет
color: monthlyExpenses > 2000 ? Color(uiColor: .systemRed) : Color(uiColor: .systemBlue)
)
.padding(.bottom)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(categoryExpenses, id: \.category.id) { item in
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Image(systemName: item.category.icon)
.font(.title2)
.foregroundColor(.white)
.frame(width: 44, height: 44)
.background(Color(item.category.color))
.cornerRadius(12)
VStack(alignment: .leading, spacing: 4) {
Text(item.category.name)
.font(.headline)
Text(item.amount.formatAsCurrency())
.font(.subheadline)
.foregroundColor(.secondary)
}
}
// Progress Bar
VStack(alignment: .leading, spacing: 8) {
GeometryReader { geometry in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color(uiColor: .systemFill))
.frame(height: 8)
RoundedRectangle(cornerRadius: 4)
.fill(Color(item.category.color))
.frame(width: geometry.size.width * CGFloat(item.percentage), height: 8)
}
}
.frame(height: 8)
Text(String(format: "%.1f%%", item.percentage * 100))
.font(.caption)
.foregroundColor(.secondary)
}
}
.frame(width: 200)
.padding()
.background(Color(uiColor: .secondarySystemBackground))
.cornerRadius(16)
}
}
.padding(.horizontal)
}
}
.padding(.horizontal)
}

View file

@ -6,19 +6,13 @@ struct TransactionsView: View {
@State private var showingFilters = false
@State private var searchText = ""
@State private var filter = TransactionFilter()
@State private var sortOrder = TransactionsStore.SortOrder.dateDescending
@State private var transactionToEdit: TransactionModel?
private var filteredAndSortedTransactions: [TransactionModel] {
let filtered = store.filterTransactions(searchText: searchText, filter: filter)
return store.sortTransactions(filtered, by: sortOrder)
}
private var groupedTransactions: [(String, [TransactionModel])] {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
let grouped = Dictionary(grouping: filteredAndSortedTransactions) { transaction in
let filtered = store.filterTransactions(searchText: searchText, filter: filter)
let grouped = Dictionary(grouping: filtered) { transaction in
formatter.string(from: transaction.date)
}
@ -28,25 +22,22 @@ struct TransactionsView: View {
var body: some View {
NavigationView {
ZStack {
Color(uiColor: .systemBackground)
Color(uiColor: .systemGroupedBackground)
.ignoresSafeArea()
if filteredAndSortedTransactions.isEmpty {
if groupedTransactions.isEmpty {
EmptyTransactionsView()
} else {
ScrollView {
LazyVStack(spacing: 24) {
ForEach(groupedTransactions, id: \.0) { month, transactions in
VStack(spacing: 8) {
VStack(alignment: .leading, spacing: 12) {
// Month Header
HStack {
Text(month)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color(uiColor: .secondaryLabel))
.textCase(.uppercase)
Spacer()
}
.padding(.horizontal)
Text(month)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color(uiColor: .secondaryLabel))
.textCase(.uppercase)
.padding(.horizontal)
// Transactions
VStack(spacing: 1) {
@ -54,7 +45,7 @@ struct TransactionsView: View {
TransactionRowView(transaction: transaction)
.contextMenu {
Button {
transactionToEdit = transaction
// Edit transaction
} label: {
Label("Edit", systemImage: "pencil")
}
@ -65,26 +56,15 @@ struct TransactionsView: View {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
store.deleteTransaction(transaction)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
transactionToEdit = transaction
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.orange)
}
}
}
.background(Color(uiColor: .secondarySystemGroupedBackground))
.cornerRadius(12)
.padding(.horizontal)
}
}
.padding(.top)
}
.padding(.top)
}
}
}
@ -92,20 +72,6 @@ struct TransactionsView: View {
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Menu {
Picker("Sort by", selection: $sortOrder) {
Label("Latest First", systemImage: "arrow.down").tag(TransactionsStore.SortOrder.dateDescending)
Label("Oldest First", systemImage: "arrow.up").tag(TransactionsStore.SortOrder.dateAscending)
Label("Largest Amount", systemImage: "arrow.down").tag(TransactionsStore.SortOrder.amountDescending)
Label("Smallest Amount", systemImage: "arrow.up").tag(TransactionsStore.SortOrder.amountAscending)
}
} label: {
Image(systemName: "arrow.up.arrow.down")
.foregroundColor(.accentColor)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingFilters = true
} label: {
@ -130,16 +96,23 @@ struct TransactionsView: View {
)
}
.sheet(isPresented: $showingAddTransaction) {
AddTransactionView(addTransaction: store.addTransaction)
}
.sheet(item: $transactionToEdit) { transaction in
AddTransactionView(
editingTransaction: transaction,
addTransaction: store.updateTransaction
)
NavigationView {
AddTransactionView { transaction in
store.addTransaction(transaction)
}
}
}
.sheet(isPresented: $showingFilters) {
TransactionFilterView(filter: $filter)
NavigationView {
TransactionFilterView(filter: $filter)
}
}
}
}
struct TransactionsView_Previews: PreviewProvider {
static var previews: some View {
TransactionsView(store: TransactionsStore())
.environmentObject(AppSettings.shared)
}
}

View file

@ -9,12 +9,12 @@ struct TransactionRowView: View {
}
var body: some View {
HStack(spacing: 12) {
HStack(spacing: 16) {
// Category Icon
ZStack {
Circle()
.fill(category.color.opacity(0.1))
.frame(width: 44, height: 44)
.fill(category.color.opacity(0.15))
.frame(width: 48, height: 48)
Image(systemName: category.icon)
.font(.system(size: 20))
@ -24,10 +24,12 @@ struct TransactionRowView: View {
// Transaction Details
VStack(alignment: .leading, spacing: 4) {
Text(transaction.category)
.font(.headline)
.font(.system(size: 17, weight: .semibold))
.foregroundColor(Color(uiColor: .label))
if let note = transaction.note {
Text(note)
.font(.subheadline)
.font(.system(size: 15))
.foregroundColor(Color(uiColor: .secondaryLabel))
}
}
@ -36,17 +38,25 @@ struct TransactionRowView: View {
// Amount and Date
VStack(alignment: .trailing, spacing: 4) {
Text(transaction.amountInCurrentCurrency().formatAsCurrency())
.font(.headline)
Text(transaction.isExpense ? "-" : "+")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(transaction.isExpense ?
Color(uiColor: .systemRed) :
Color(uiColor: .systemGreen))
+ Text(transaction.amountInCurrentCurrency().formatAsCurrency())
.font(.system(size: 17, weight: .semibold))
.foregroundColor(transaction.isExpense ?
Color(uiColor: .systemRed) :
Color(uiColor: .systemGreen))
Text(transaction.date, style: .date)
.font(.caption)
.font(.system(size: 13))
.foregroundColor(Color(uiColor: .secondaryLabel))
}
}
.padding(.vertical, 8)
.padding(.vertical, 12)
.padding(.horizontal, 16)
.background(Color(uiColor: .secondarySystemGroupedBackground))
}
}