Fix transaction creation and editing functionality. Update TransactionModel initialization and improve AddTransactionView

This commit is contained in:
“SamoilenkoVadym” 2025-03-02 20:46:33 +00:00
parent ce09f987c9
commit 79e649756a
7 changed files with 392 additions and 220 deletions

View file

@ -9,7 +9,20 @@ struct TransactionModel: Identifiable, Codable {
var note: String?
var originalCurrency: AppSettings.Currency
init(amount: Double, date: Date, type: TransactionType, category: String, note: String?, originalCurrency: AppSettings.Currency) {
var isExpense: Bool {
type == .expense
}
var signedAmount: Double {
isExpense ? -amount : amount
}
init(amount: Double,
date: Date,
type: TransactionType,
category: String,
note: String? = nil,
originalCurrency: AppSettings.Currency) {
self.id = UUID().uuidString
self.amount = amount
self.date = date
@ -19,14 +32,6 @@ struct TransactionModel: Identifiable, Codable {
self.originalCurrency = originalCurrency
}
var isExpense: Bool {
type == .expense
}
var signedAmount: Double {
isExpense ? -amount : amount
}
func amountInCurrentCurrency() -> Double {
let settings = AppSettings.shared
return settings.convert(amount, from: originalCurrency, to: settings.currency)

View file

@ -3,22 +3,72 @@ import Foundation
class TransactionsStore: ObservableObject {
@Published private(set) var transactions: [TransactionModel] = TransactionModel.sampleData
init() {
NotificationCenter.default.addObserver(self,
selector: #selector(currencyDidChange),
name: .currencyDidChange,
object: nil)
}
// CRUD операции
func addTransaction(_ transaction: TransactionModel) {
transactions.insert(transaction, at: 0)
}
func updateTransaction(_ transaction: TransactionModel) {
if let index = transactions.firstIndex(where: { $0.id == transaction.id }) {
transactions[index] = transaction
}
}
func deleteTransaction(_ transaction: TransactionModel) {
transactions.removeAll { $0.id == transaction.id }
}
func deleteTransaction(at indexSet: IndexSet) {
transactions.remove(atOffsets: indexSet)
}
@objc private func currencyDidChange() {
objectWillChange.send()
// Фильтрация
func filterTransactions(searchText: String, filter: TransactionFilter) -> [TransactionModel] {
var filteredTransactions = transactions
// Фильтр по поисковому запросу
if !searchText.isEmpty {
filteredTransactions = filteredTransactions.filter { transaction in
let searchString = searchText.lowercased()
return transaction.category.lowercased().contains(searchString) ||
transaction.note?.lowercased().contains(searchString) ?? false
}
}
// Фильтр по типу транзакции
if let type = filter.transactionType {
filteredTransactions = filteredTransactions.filter { $0.type == type }
}
// Фильтр по категориям
if !filter.selectedCategories.isEmpty {
filteredTransactions = filteredTransactions.filter { filter.selectedCategories.contains($0.category) }
}
// Фильтр по периоду
filteredTransactions = filteredTransactions.filter { filter.period.filter($0.date) }
return filteredTransactions
}
// Сортировка
func sortTransactions(_ transactions: [TransactionModel], by sortOrder: SortOrder = .dateDescending) -> [TransactionModel] {
switch sortOrder {
case .dateAscending:
return transactions.sorted { $0.date < $1.date }
case .dateDescending:
return transactions.sorted { $0.date > $1.date }
case .amountAscending:
return transactions.sorted { $0.amount < $1.amount }
case .amountDescending:
return transactions.sorted { $0.amount > $1.amount }
}
}
enum SortOrder {
case dateAscending
case dateDescending
case amountAscending
case amountDescending
}
}

View file

@ -4,20 +4,75 @@ struct AddTransactionView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var settings: AppSettings
@State private var amount = ""
@State private var note = ""
@State private var category = CategoryModel.categories[0].name
@State private var date = Date()
@State private var type: TransactionType = .expense
@State private var amount: String
@State private var note: String
@State private var category: String
@State private var date: Date
@State private var type: TransactionType
@State private var showingCategories = false
@State private var showingDeleteConfirmation = false
let addTransaction: (TransactionModel) -> Void
let editingTransaction: TransactionModel?
let onDelete: (() -> Void)?
// Инициализатор для создания новой транзакции
init(addTransaction: @escaping (TransactionModel) -> Void) {
self.addTransaction = addTransaction
self.editingTransaction = nil
self.onDelete = nil
_amount = State(initialValue: "")
_note = State(initialValue: "")
_category = State(initialValue: CategoryModel.categories[0].name)
_date = State(initialValue: Date())
_type = State(initialValue: .expense)
}
// Инициализатор для редактирования существующей транзакции
init(editingTransaction: TransactionModel,
addTransaction: @escaping (TransactionModel) -> Void,
onDelete: (() -> Void)? = nil) {
self.addTransaction = addTransaction
self.editingTransaction = editingTransaction
self.onDelete = onDelete
_amount = State(initialValue: String(format: "%.2f", editingTransaction.amount))
_note = State(initialValue: editingTransaction.note ?? "")
_category = State(initialValue: editingTransaction.category)
_date = State(initialValue: editingTransaction.date)
_type = State(initialValue: editingTransaction.type)
}
private var isValidAmount: Bool {
guard let amountDouble = Double(amount) else { return false }
return amountDouble > 0
}
private func createTransaction() -> TransactionModel? {
guard let amountDouble = Double(amount) else { return nil }
return TransactionModel(
amount: amountDouble,
date: date,
type: type,
category: category,
note: note.isEmpty ? nil : note,
originalCurrency: settings.currency
)
}
private var navigationTitle: String {
editingTransaction == nil ? "New Transaction" : "Edit Transaction"
}
private var saveButtonTitle: String {
editingTransaction == nil ? "Add" : "Save"
}
private func handleSave() {
guard let transaction = createTransaction() else { return }
addTransaction(transaction)
dismiss()
}
var body: some View {
NavigationView {
ZStack {
@ -56,9 +111,9 @@ struct AddTransactionView: View {
// Details
VStack(spacing: 0) {
Button(action: {
Button {
showingCategories = true
}) {
} label: {
HStack {
let selectedCategory = CategoryModel.category(for: category)
Image(systemName: selectedCategory.icon)
@ -103,11 +158,28 @@ struct AddTransactionView: View {
.background(Color(uiColor: .secondarySystemGroupedBackground))
.cornerRadius(12)
.padding(.horizontal)
if editingTransaction != nil {
Button {
showingDeleteConfirmation = true
} label: {
HStack {
Image(systemName: "trash")
Text("Delete Transaction")
}
.foregroundColor(.red)
.padding()
.frame(maxWidth: .infinity)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.cornerRadius(12)
}
.padding(.horizontal)
}
}
.padding(.top, 20)
}
}
.navigationTitle("New Transaction")
.navigationTitle(navigationTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
@ -117,22 +189,21 @@ struct AddTransactionView: View {
}
ToolbarItem(placement: .confirmationAction) {
Button("Add") {
guard let amountDouble = Double(amount) else { return }
let transaction = TransactionModel(
amount: amountDouble,
date: date,
type: type,
category: category,
note: note.isEmpty ? nil : note,
originalCurrency: settings.currency
)
addTransaction(transaction)
dismiss()
Button(saveButtonTitle) {
handleSave()
}
.disabled(!isValidAmount)
}
}
.alert("Delete Transaction", isPresented: $showingDeleteConfirmation) {
Button("Cancel", role: .cancel) { }
Button("Delete", role: .destructive) {
onDelete?()
dismiss()
}
} message: {
Text("Are you sure you want to delete this transaction? This action cannot be undone.")
}
}
.sheet(isPresented: $showingCategories) {
CategoryPickerView(selectedCategory: $category)
@ -140,57 +211,17 @@ struct AddTransactionView: View {
}
}
// Вспомогательное view для выбора категории
struct CategoryPickerView: View {
@Environment(\.dismiss) private var dismiss
@Binding var selectedCategory: String
var body: some View {
NavigationView {
List {
ForEach(CategoryModel.categories) { category in
Button {
selectedCategory = category.name
dismiss()
} label: {
HStack {
Image(systemName: category.icon)
.foregroundColor(Color(category.color))
.frame(width: 30)
Text(category.name)
.foregroundColor(AppStyle.labelPrimary)
Spacer()
if selectedCategory == category.name {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
}
.navigationTitle("Select Category")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
}
}
struct AddTransactionView_Previews: PreviewProvider {
static var previews: some View {
Group {
AddTransactionView(addTransaction: { _ in })
.preferredColorScheme(.light)
AddTransactionView(addTransaction: { _ in })
AddTransactionView(
editingTransaction: TransactionModel.sampleData[0],
addTransaction: { _ in },
onDelete: { }
)
.preferredColorScheme(.dark)
}
.environmentObject(AppSettings.shared)

View file

@ -3,9 +3,8 @@ import SwiftUI
struct TransactionFilterView: View {
@Environment(\.dismiss) private var dismiss
@Binding var filter: TransactionFilter
@State private var selectedPeriodIndex = 0
private let periods: [(name: String, period: TransactionFilter.Period)] = [
private let periods: [(title: String, period: TransactionFilter.Period)] = [
("All Time", .all),
("Today", .today),
("Last 7 Days", .week),
@ -17,14 +16,19 @@ struct TransactionFilterView: View {
List {
// Period Section
Section {
Picker("Time Period", selection: $selectedPeriodIndex) {
ForEach(0..<periods.count, id: \.self) { index in
Text(periods[index].name).tag(index)
ForEach(periods, id: \.title) { period in
HStack {
Text(period.title)
Spacer()
if filter.period == period.period {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
.contentShape(Rectangle())
.onTapGesture {
filter.period = period.period
}
}
.pickerStyle(.segmented)
.onChange(of: selectedPeriodIndex) { newValue in
filter.period = periods[newValue].period
}
} header: {
Text("Time Period")
@ -32,46 +36,24 @@ struct TransactionFilterView: View {
// Type Section
Section {
HStack {
Text("All")
Spacer()
if filter.transactionType == nil {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
ForEach([
("All Transactions", nil),
("Income", TransactionType.income),
("Expense", TransactionType.expense)
], id: \.0) { title, type in
HStack {
Text(title)
Spacer()
if filter.transactionType == type {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
.contentShape(Rectangle())
.onTapGesture {
filter.transactionType = nil
}
HStack {
Label("Income", systemImage: "arrow.down.circle.fill")
.foregroundColor(.green)
Spacer()
if filter.transactionType == .income {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
.contentShape(Rectangle())
.onTapGesture {
filter.transactionType = type
}
}
.contentShape(Rectangle())
.onTapGesture {
filter.transactionType = .income
}
HStack {
Label("Expense", systemImage: "arrow.up.circle.fill")
.foregroundColor(.red)
Spacer()
if filter.transactionType == .expense {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
.contentShape(Rectangle())
.onTapGesture {
filter.transactionType = .expense
}
} header: {
Text("Transaction Type")
}
@ -79,22 +61,24 @@ struct TransactionFilterView: View {
// Categories Section
Section {
ForEach(CategoryModel.categories) { category in
Toggle(isOn: Binding(
get: { filter.selectedCategories.contains(category.name) },
set: { isSelected in
if isSelected {
filter.selectedCategories.insert(category.name)
} else {
filter.selectedCategories.remove(category.name)
}
}
)) {
let isSelected = filter.selectedCategories.contains(category.name)
HStack {
HStack {
Image(systemName: category.icon)
.foregroundColor(Color(category.color))
.frame(width: 24)
Text(category.name)
}
Spacer()
Toggle("", isOn: Binding(
get: { isSelected },
set: { newValue in
if newValue {
filter.selectedCategories.insert(category.name)
} else {
filter.selectedCategories.remove(category.name)
}
}
))
}
}
} header: {
@ -115,7 +99,6 @@ struct TransactionFilterView: View {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Reset") {
filter = TransactionFilter()
selectedPeriodIndex = 0
}
}
@ -129,31 +112,9 @@ struct TransactionFilterView: View {
}
}
// Preview
struct TransactionFilterView_Previews: PreviewProvider {
static var previews: some View {
Group {
TransactionFilterView(
filter: .constant(TransactionFilter())
)
.preferredColorScheme(.light)
TransactionFilterView(
filter: .constant(TransactionFilter())
)
.preferredColorScheme(.dark)
}
}
}
// Helper extension для красивого отображения периодов
extension TransactionFilter.Period {
var displayName: String {
switch self {
case .all: return "All Time"
case .today: return "Today"
case .week: return "Last 7 Days"
case .month: return "Last 30 Days"
}
TransactionFilterView(filter: .constant(TransactionFilter()))
.environmentObject(AppSettings.shared)
}
}

View file

@ -5,12 +5,20 @@ struct TransactionsView: View {
@State private var showingAddTransaction = false
@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: store.transactions) { transaction in
let grouped = Dictionary(grouping: filteredAndSortedTransactions) { transaction in
formatter.string(from: transaction.date)
}
@ -23,36 +31,81 @@ struct TransactionsView: View {
Color(uiColor: .systemBackground)
.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: 24) {
ForEach(groupedTransactions, id: \.0) { month, transactions in
VStack(spacing: 8) {
// Month Header
HStack {
Text(month)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color(uiColor: .secondaryLabel))
.textCase(.uppercase)
Spacer()
}
.padding(.horizontal)
// Transactions
VStack(spacing: 1) {
ForEach(transactions) { transaction in
TransactionRowView(transaction: transaction)
if filteredAndSortedTransactions.isEmpty {
EmptyTransactionsView()
} else {
ScrollView {
LazyVStack(spacing: 24) {
ForEach(groupedTransactions, id: \.0) { month, transactions in
VStack(spacing: 8) {
// Month Header
HStack {
Text(month)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color(uiColor: .secondaryLabel))
.textCase(.uppercase)
Spacer()
}
.padding(.horizontal)
// Transactions
VStack(spacing: 1) {
ForEach(transactions) { transaction in
TransactionRowView(transaction: transaction)
.contextMenu {
Button {
transactionToEdit = transaction
} label: {
Label("Edit", systemImage: "pencil")
}
Button(role: .destructive) {
store.deleteTransaction(transaction)
} label: {
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)
}
}
}
}
}
}
.padding(.top)
}
.padding(.top)
}
}
.navigationTitle("Transactions")
.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: {
@ -79,42 +132,14 @@ struct TransactionsView: View {
.sheet(isPresented: $showingAddTransaction) {
AddTransactionView(addTransaction: store.addTransaction)
}
.sheet(item: $transactionToEdit) { transaction in
AddTransactionView(
editingTransaction: transaction,
addTransaction: store.updateTransaction
)
}
.sheet(isPresented: $showingFilters) {
TransactionFilterView(filter: .constant(TransactionFilter()))
TransactionFilterView(filter: $filter)
}
}
}
// Вспомогательное view для пустого состояния
struct EmptyTransactionsView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "creditcard")
.font(.system(size: 48))
.foregroundColor(Color(uiColor: .systemGray))
Text("No Transactions")
.font(.title3)
.fontWeight(.semibold)
Text("Start adding your transactions to track your expenses and income")
.font(.subheadline)
.foregroundColor(Color(uiColor: .secondaryLabel))
.multilineTextAlignment(.center)
}
.padding()
}
}
struct TransactionsView_Previews: PreviewProvider {
static var previews: some View {
Group {
TransactionsView(store: TransactionsStore())
.preferredColorScheme(.light)
TransactionsView(store: TransactionsStore())
.preferredColorScheme(.dark)
}
.environmentObject(AppSettings.shared)
}
}

View file

@ -0,0 +1,58 @@
//
// CategoryPickerView.swift
// Coinly
//
// Created by Vadym Samoilenko on 02/03/2025.
//
import SwiftUI
struct CategoryPickerView: View {
@Environment(\.dismiss) private var dismiss
@Binding var selectedCategory: String
var body: some View {
NavigationView {
List {
ForEach(CategoryModel.categories) { category in
Button {
selectedCategory = category.name
dismiss()
} label: {
HStack {
Image(systemName: category.icon)
.foregroundColor(Color(category.color))
.frame(width: 30)
Text(category.name)
.foregroundColor(AppStyle.labelPrimary)
Spacer()
if selectedCategory == category.name {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
}
.navigationTitle("Select Category")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
}
}
struct CategoryPickerView_Previews: PreviewProvider {
static var previews: some View {
CategoryPickerView(selectedCategory: .constant(CategoryModel.categories[0].name))
}
}

View file

@ -0,0 +1,42 @@
//
// EmptyTransactionsView.swift
// Coinly
//
// Created by Vadym Samoilenko on 02/03/2025.
//
import SwiftUI
struct EmptyTransactionsView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "creditcard")
.font(.system(size: 48))
.foregroundColor(Color(uiColor: .systemGray))
Text("No Transactions")
.font(.title3)
.fontWeight(.semibold)
Text("Start adding your transactions to track your expenses and income")
.font(.subheadline)
.foregroundColor(Color(uiColor: .secondaryLabel))
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding()
}
}
struct EmptyTransactionsView_Previews: PreviewProvider {
static var previews: some View {
Group {
EmptyTransactionsView()
.preferredColorScheme(.light)
EmptyTransactionsView()
.preferredColorScheme(.dark)
}
}
}