Improve transaction UI, add budget progress and spending analytics
This commit is contained in:
parent
148f357aa2
commit
d7d8de38f1
3 changed files with 194 additions and 76 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue