From d957385a176f30ec1dfb1c0cfb074b922e7d9390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CSamoilenkoVadym=E2=80=9D?= <“samoylenko.vadym@gmail.com”> Date: Mon, 3 Mar 2025 10:36:57 +0000 Subject: [PATCH] Update --- Coinly/App/ContentView.swift | 7 + Coinly/Features/Dashboard/DashboardView.swift | 173 +++++++----------- .../Transactions/TransactionFilterView.swift | 123 ++++++------- .../TransactionsSummaryView.swift | 60 ++++++ .../Transactions/TransactionsView.swift | 122 +++++++++--- 5 files changed, 282 insertions(+), 203 deletions(-) create mode 100644 Coinly/Features/Transactions/TransactionsSummaryView.swift diff --git a/Coinly/App/ContentView.swift b/Coinly/App/ContentView.swift index 1bf32ab..38c6a70 100644 --- a/Coinly/App/ContentView.swift +++ b/Coinly/App/ContentView.swift @@ -15,6 +15,13 @@ struct ContentView: View { Label("Dashboard", systemImage: "chart.pie.fill") } + NavigationView { + TransactionsView() + } + .tabItem { + Label("Transactions", systemImage: "arrow.left.arrow.right") + } + NavigationView { AccountsListView() } diff --git a/Coinly/Features/Dashboard/DashboardView.swift b/Coinly/Features/Dashboard/DashboardView.swift index e8232f5..6fe1dbd 100644 --- a/Coinly/Features/Dashboard/DashboardView.swift +++ b/Coinly/Features/Dashboard/DashboardView.swift @@ -4,123 +4,59 @@ struct DashboardView: View { @EnvironmentObject private var accountsStore: AccountsStore @EnvironmentObject private var transactionsStore: TransactionsStore @EnvironmentObject private var settings: AppSettings - @State private var selectedAccountId: AccountModel.ID? = nil @State private var showingAddTransaction = false @State private var showingFilter = false @State private var currentFilter = TransactionFilter() - - private var selectedAccount: AccountModel? { - selectedAccountId.flatMap { id in - accountsStore.getAccount(withId: id) - } ?? accountsStore.accounts.first - } - - private var filteredTransactions: [TransactionModel] { - var filter = currentFilter - filter.accountId = selectedAccountId - return transactionsStore.filterTransactions(filter: filter) - } - - private func getFilteredTransactions(type: TransactionType?) -> [TransactionModel] { - let filter = TransactionFilter( - type: type, - accountId: selectedAccountId, - period: .month - ) - return transactionsStore.filterTransactions(filter: filter) - } - - private var monthlyIncome: Double { - let transactions = getFilteredTransactions(type: .income) - return transactions.reduce(0) { $0 + $1.amount } - } - - private var monthlyExpenses: Double { - let transactions = getFilteredTransactions(type: .expense) - return transactions.reduce(0) { $0 + $1.amount } - } - - private func handleAddTransaction(_ transaction: TransactionModel) { - transactionsStore.addTransaction(transaction) - if let accountId = transaction.accountId, - let account = accountsStore.getAccount(withId: accountId) { - let amount = transaction.type == .income ? transaction.amount : -transaction.amount - let newBalance = account.balance + amount - accountsStore.updateAccount(account.with(balance: newBalance)) - } - } + @State private var selectedAccountId: String? var body: some View { - ScrollView { - VStack(spacing: 16) { - // Account Cards Carousel + List { + Section { ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(spacing: 16) { + LazyHStack(spacing: 12) { ForEach(accountsStore.accounts) { account in AccountCardView( account: account, - isSelected: account.id == selectedAccountId + isSelected: selectedAccountId == account.id ) .onTapGesture { - withAnimation { - selectedAccountId = account.id - } + selectedAccountId = account.id } } } .padding(.horizontal) } - .frame(height: 200) + .frame(height: 160) + } + + Section { + StatCard( + title: "Income", + amount: totalIncome, + color: .green, + currency: settings.selectedCurrency + ) - // Stats - HStack(spacing: 16) { - StatCard( - title: "Income", - amount: monthlyIncome, - color: .green, - currency: settings.selectedCurrency - ) - - StatCard( - title: "Expenses", - amount: monthlyExpenses, - color: .red, - currency: settings.selectedCurrency - ) + StatCard( + title: "Expenses", + amount: totalExpenses, + color: .red, + currency: settings.selectedCurrency + ) + } + + Section { + ForEach(filteredTransactions) { transaction in + TransactionRowView(transaction: transaction) } - .padding(.horizontal) - - // Transactions List - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Transactions") - .font(.title2.bold()) - - Spacer() - - Button { - showingFilter = true - } label: { - Image(systemName: "line.3.horizontal.decrease.circle") - .foregroundColor(.accentColor) - } - } - .padding(.horizontal) - - if filteredTransactions.isEmpty { - ContentUnavailableView( - "No Transactions", - systemImage: "tray.fill", - description: Text("Add your first transaction to start tracking your finances") - ) - } else { - LazyVStack(spacing: 8) { - ForEach(filteredTransactions) { transaction in - TransactionRowView(transaction: transaction) - .padding(.horizontal) - } - } + } header: { + HStack { + Text("Recent Transactions") + Spacer() + Button("See All") { + // Navigate to transactions } + .font(.caption) } } } @@ -145,16 +81,36 @@ struct DashboardView: View { } .sheet(isPresented: $showingFilter) { NavigationView { - TransactionFilterView(filter: currentFilter) { filter in - currentFilter = filter - } + TransactionFilterView(filter: $currentFilter) } } } + + private var filteredTransactions: [TransactionModel] { + transactionsStore.filterTransactions(filter: currentFilter) + } + + private var totalIncome: Double { + filteredTransactions + .filter { $0.type == .income } + .reduce(0) { $0 + $1.amount } + } + + private var totalExpenses: Double { + filteredTransactions + .filter { $0.type == .expense } + .reduce(0) { $0 + $1.amount } + } + + private func handleAddTransaction(_ transaction: TransactionModel) { + transactionsStore.addTransaction(transaction) + if let account = accountsStore.getAccount(withId: transaction.accountId ?? "") { + let newBalance = account.balance + (transaction.type == .income ? transaction.amount : -transaction.amount) + accountsStore.updateAccount(account.with(balance: newBalance)) + } + } } -// MARK: - Supporting Views - private struct StatCard: View { let title: String let amount: Double @@ -162,20 +118,15 @@ private struct StatCard: View { let currency: AppSettings.Currency var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { Text(title) - .font(.subheadline) + .font(.caption) .foregroundColor(.secondary) - Text(amount.formatted(.currency(code: currency.rawValue))) .font(.headline) .foregroundColor(color) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(Color(uiColor: .systemBackground)) - .cornerRadius(12) - .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4) + .padding(.vertical, 8) } } diff --git a/Coinly/Features/Transactions/TransactionFilterView.swift b/Coinly/Features/Transactions/TransactionFilterView.swift index 1af453c..4128503 100644 --- a/Coinly/Features/Transactions/TransactionFilterView.swift +++ b/Coinly/Features/Transactions/TransactionFilterView.swift @@ -2,99 +2,92 @@ import SwiftUI struct TransactionFilterView: View { @Environment(\.dismiss) private var dismiss - let filter: TransactionFilter - let onApply: (TransactionFilter) -> Void + @Binding var filter: TransactionFilter + @State private var temporaryFilter: TransactionFilter + @EnvironmentObject private var accountsStore: AccountsStore + @EnvironmentObject private var categoryStore: CategoryStore - @State private var type: TransactionType? - @State private var period: TransactionFilter.Period? - @State private var startDate: Date? - @State private var endDate: Date? - - init(filter: TransactionFilter, onApply: @escaping (TransactionFilter) -> Void) { - self.filter = filter - self.onApply = onApply - _type = State(initialValue: filter.type) - _period = State(initialValue: filter.period) - _startDate = State(initialValue: filter.startDate) - _endDate = State(initialValue: filter.endDate) + init(filter: Binding) { + self._filter = filter + self._temporaryFilter = State(initialValue: filter.wrappedValue) } var body: some View { - List { - Section { - Picker("Period", selection: $period) { - Text("All Time").tag(TransactionFilter.Period?.none) + Form { + Section("Period") { + Picker("Period", selection: $temporaryFilter.period) { + Text("All Time").tag(Optional.none) ForEach(TransactionFilter.Period.allCases, id: \.self) { period in Text(period.rawValue).tag(Optional(period)) } } } - Section { - Picker("Type", selection: $type) { - Text("All Types").tag(TransactionType?.none) + Section("Type") { + Picker("Type", selection: $temporaryFilter.type) { + Text("All").tag(Optional.none) ForEach(TransactionType.allCases, id: \.self) { type in Text(type.rawValue).tag(Optional(type)) } } } - Section { - DatePicker( - "Start Date", - selection: Binding( - get: { startDate ?? Date() }, - set: { startDate = $0 } - ), - displayedComponents: .date - ) - .onChange(of: startDate) { oldValue, newValue in - period = nil - } - - DatePicker( - "End Date", - selection: Binding( - get: { endDate ?? Date() }, - set: { endDate = $0 } - ), - displayedComponents: .date - ) - .onChange(of: endDate) { oldValue, newValue in - period = nil + Section("Account") { + NavigationLink { + AccountPickerView { account in + temporaryFilter.accountId = account.id + } + } label: { + HStack { + Text("Account") + Spacer() + if let accountId = temporaryFilter.accountId, + let account = accountsStore.getAccount(withId: accountId) { + Text(account.name) + .foregroundColor(.secondary) + } else { + Text("All") + .foregroundColor(.secondary) + } + } } } - Section { - Button("Clear Filter") { - type = nil - period = nil - startDate = nil - endDate = nil + Section("Category") { + NavigationLink { + CategoryPickerView( + transactionType: temporaryFilter.type ?? .expense + ) { category in + temporaryFilter.categoryId = category.id + } + } label: { + HStack { + Text("Category") + Spacer() + if let categoryId = temporaryFilter.categoryId, + let category = categoryStore.getCategory(withId: categoryId) { + Text(category.name) + .foregroundColor(.secondary) + } else { + Text("All") + .foregroundColor(.secondary) + } + } } - .foregroundColor(.red) } } .navigationTitle("Filter") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - dismiss() + Button("Reset") { + temporaryFilter = TransactionFilter() } } ToolbarItem(placement: .navigationBarTrailing) { Button("Apply") { - let newFilter = TransactionFilter( - startDate: startDate, - endDate: endDate, - type: type, - categoryId: filter.categoryId, - accountId: filter.accountId, - period: period - ) - onApply(newFilter) + filter = temporaryFilter dismiss() } } @@ -104,8 +97,8 @@ struct TransactionFilterView: View { #Preview { NavigationView { - TransactionFilterView( - filter: TransactionFilter() - ) { _ in } + TransactionFilterView(filter: .constant(TransactionFilter())) + .environmentObject(AccountsStore.shared) + .environmentObject(CategoryStore.shared) } } diff --git a/Coinly/Features/Transactions/TransactionsSummaryView.swift b/Coinly/Features/Transactions/TransactionsSummaryView.swift new file mode 100644 index 0000000..0b78c3c --- /dev/null +++ b/Coinly/Features/Transactions/TransactionsSummaryView.swift @@ -0,0 +1,60 @@ +// +// TransactionsSummaryView.swift +// Coinly +// +// Created by Vadym Samoilenko on 03/03/2025. +// + + +import SwiftUI + +struct TransactionsSummaryView: View { + let transactions: [TransactionModel] + @EnvironmentObject private var settings: AppSettings + + private var income: Double { + transactions + .filter { $0.type == .income } + .reduce(0) { $0 + $1.amount } + } + + private var expenses: Double { + transactions + .filter { $0.type == .expense } + .reduce(0) { $0 + $1.amount } + } + + var body: some View { + VStack(spacing: 8) { + HStack { + VStack(alignment: .leading) { + Text("Income") + .font(.caption) + .foregroundColor(.secondary) + Text(income.formatted(.currency(code: settings.selectedCurrency.rawValue))) + .font(.headline) + .foregroundColor(.green) + } + + Spacer() + + VStack(alignment: .trailing) { + Text("Expenses") + .font(.caption) + .foregroundColor(.secondary) + Text(expenses.formatted(.currency(code: settings.selectedCurrency.rawValue))) + .font(.headline) + .foregroundColor(.primary) + } + } + } + .padding(.vertical, 8) + .textCase(nil) + } +} + +#Preview { + TransactionsSummaryView(transactions: TransactionModel.previewItems) + .environmentObject(AppSettings.shared) + .padding() +} diff --git a/Coinly/Features/Transactions/TransactionsView.swift b/Coinly/Features/Transactions/TransactionsView.swift index d680fa4..e9de905 100644 --- a/Coinly/Features/Transactions/TransactionsView.swift +++ b/Coinly/Features/Transactions/TransactionsView.swift @@ -1,44 +1,76 @@ import SwiftUI struct TransactionsView: View { - @EnvironmentObject private var store: TransactionsStore - @State private var searchText = "" - @State private var showingFilter = false + @EnvironmentObject private var transactionsStore: TransactionsStore + @EnvironmentObject private var accountsStore: AccountsStore + @EnvironmentObject private var categoryStore: CategoryStore @State private var showingAddTransaction = false - - var title: String - var filter: TransactionFilter - - init(title: String = "Transactions", filter: TransactionFilter = TransactionFilter()) { - self.title = title - self.filter = filter - } + @State private var showingFilter = false + @State private var currentFilter = TransactionFilter() private var filteredTransactions: [TransactionModel] { - store.filterTransactions(filter: filter) + transactionsStore.filterTransactions(filter: currentFilter) } var body: some View { List { - ForEach(filteredTransactions) { transaction in - TransactionRowView(transaction: transaction) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - store.deleteTransaction(transaction) - } label: { - Label("Delete", systemImage: "trash") + if !filteredTransactions.isEmpty { + Section { + ForEach(filteredTransactions) { transaction in + TransactionRowView(transaction: transaction) + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + deleteTransaction(transaction) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } header: { + VStack(spacing: 8) { + HStack { + VStack(alignment: .leading) { + Text("Income") + .font(.caption) + .foregroundColor(.secondary) + Text(totalIncome.formatted(.currency(code: "USD"))) + .font(.headline) + .foregroundColor(.green) + } + + Spacer() + + VStack(alignment: .trailing) { + Text("Expenses") + .font(.caption) + .foregroundColor(.secondary) + Text(totalExpenses.formatted(.currency(code: "USD"))) + .font(.headline) + .foregroundColor(.red) + } + } + + HStack { + Text("Balance") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(balance.formatted(.currency(code: "USD"))) + .font(.headline) } } + .textCase(nil) + .padding(.vertical, 8) + } } } - .navigationTitle(title) - .searchable(text: $searchText, prompt: "Search transactions") + .navigationTitle("Transactions") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { showingAddTransaction = true } label: { - Image(systemName: "plus") + Image(systemName: "plus.circle.fill") } } @@ -53,17 +85,53 @@ struct TransactionsView: View { .sheet(isPresented: $showingAddTransaction) { NavigationView { AddTransactionView { transaction in - store.addTransaction(transaction) + transactionsStore.addTransaction(transaction) + if let account = accountsStore.getAccount(withId: transaction.accountId ?? "") { + let newBalance = account.balance + (transaction.type == .income ? transaction.amount : -transaction.amount) + accountsStore.updateAccount(account.with(balance: newBalance)) + } } } } .sheet(isPresented: $showingFilter) { NavigationView { - TransactionFilterView(filter: filter) { newFilter in - // Handle filter update if needed - } + TransactionFilterView(filter: $currentFilter) } } + .overlay { + if filteredTransactions.isEmpty { + ContentUnavailableView( + "No Transactions", + systemImage: "arrow.left.arrow.right", + description: Text("Add your first transaction to start tracking your finances") + ) + } + } + } + + private var totalIncome: Double { + filteredTransactions + .filter { $0.type == .income } + .reduce(0) { $0 + $1.amount } + } + + private var totalExpenses: Double { + filteredTransactions + .filter { $0.type == .expense } + .reduce(0) { $0 + $1.amount } + } + + private var balance: Double { + totalIncome - totalExpenses + } + + private func deleteTransaction(_ transaction: TransactionModel) { + if let account = accountsStore.getAccount(withId: transaction.accountId ?? "") { + let amount = transaction.type == .income ? -transaction.amount : transaction.amount + let newBalance = account.balance + amount + accountsStore.updateAccount(account.with(balance: newBalance)) + } + transactionsStore.deleteTransaction(transaction) } } @@ -71,7 +139,7 @@ struct TransactionsView: View { NavigationView { TransactionsView() .environmentObject(TransactionsStore.shared) + .environmentObject(AccountsStore.shared) .environmentObject(CategoryStore.shared) - .environmentObject(AppSettings.shared) } }