diff --git a/Coinly/App/CoinlyApp.swift b/Coinly/App/CoinlyApp.swift index 576d978..6323cd1 100644 --- a/Coinly/App/CoinlyApp.swift +++ b/Coinly/App/CoinlyApp.swift @@ -1,20 +1,19 @@ -// -// CoinlyApp.swift -// Coinly -// -// Created by Vadym Samoilenko on 02/03/2025. -// - import SwiftUI @main struct CoinlyApp: App { - let persistenceController = PersistenceController.shared + @StateObject private var settings = AppSettings.shared + @StateObject private var transactionsStore = TransactionsStore.shared + @StateObject private var accountsStore = AccountsStore.shared + @StateObject private var categoryStore = CategoryStore.shared var body: some Scene { WindowGroup { ContentView() - .environment(\.managedObjectContext, persistenceController.container.viewContext) + .environmentObject(settings) + .environmentObject(transactionsStore) + .environmentObject(accountsStore) + .environmentObject(categoryStore) } } } diff --git a/Coinly/App/ContentView.swift b/Coinly/App/ContentView.swift index 4a47ab8..304b4fe 100644 --- a/Coinly/App/ContentView.swift +++ b/Coinly/App/ContentView.swift @@ -1,38 +1,43 @@ import SwiftUI struct ContentView: View { - @StateObject private var transactionsStore = TransactionsStore() - @StateObject private var accountsStore = AccountsStore() - @StateObject private var settings = AppSettings.shared - var body: some View { TabView { NavigationView { - DashboardView( - store: transactionsStore, - accountsStore: accountsStore - ) + DashboardView() } .tabItem { - Label("Dashboard", systemImage: "chart.pie.fill") + Label("Dashboard", systemImage: "chart.pie") } - TransactionsView(store: transactionsStore) - .tabItem { - Label("Transactions", systemImage: "list.bullet") - } + NavigationView { + AccountListView() + } + .tabItem { + Label("Accounts", systemImage: "creditcard") + } - ProfileView() - .tabItem { - Label("Profile", systemImage: "person.fill") - } + NavigationView { + CategoryListView(type: .expense) + } + .tabItem { + Label("Categories", systemImage: "tag") + } + + NavigationView { + ProfileView() + } + .tabItem { + Label("Profile", systemImage: "person") + } } - .environmentObject(settings) } } -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } +#Preview { + ContentView() + .environmentObject(TransactionsStore.shared) + .environmentObject(AccountsStore.shared) + .environmentObject(CategoryStore.shared) + .environmentObject(AppSettings.shared) } diff --git a/Coinly/Core/Utils/AppCurrencyFormatter.swift b/Coinly/Core/Utils/AppCurrencyFormatter.swift new file mode 100644 index 0000000..193ae0d --- /dev/null +++ b/Coinly/Core/Utils/AppCurrencyFormatter.swift @@ -0,0 +1,20 @@ +import Foundation + +struct AppCurrencyFormatter { + static func format(_ amount: Double, currency: AppSettings.Currency = AppSettings.shared.selectedCurrency) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currency.rawValue + formatter.minimumFractionDigits = 2 + formatter.maximumFractionDigits = 2 + + return formatter.string(from: NSNumber(value: amount)) ?? "\(currency.symbol)\(amount)" + } +} + +// Добавляем расширение для Double для удобного форматирования +extension Double { + func formatAsCurrency(currency: AppSettings.Currency = AppSettings.shared.selectedCurrency) -> String { + AppCurrencyFormatter.format(self, currency: currency) + } +} diff --git a/Coinly/Core/Utils/CurrencyFormatter.swift b/Coinly/Core/Utils/CurrencyFormatter.swift deleted file mode 100644 index e42c7ed..0000000 --- a/Coinly/Core/Utils/CurrencyFormatter.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -extension Double { - func formatAsCurrency() -> String { - let settings = AppSettings.shared - return String(format: "%@%.2f", settings.currency.symbol, self) - } -} diff --git a/Coinly/Extensions/Color+Extensions.swift b/Coinly/Extensions/Color+Extensions.swift index e69de29..8e3ec0c 100644 --- a/Coinly/Extensions/Color+Extensions.swift +++ b/Coinly/Extensions/Color+Extensions.swift @@ -0,0 +1,33 @@ +// +// Color+Extensions.swift +// Coinly +// +// Created by Vadym Samoilenko on 02/03/2025. +// + +import SwiftUI + +extension Color { + init(_ systemColor: String) { + switch systemColor { + case "systemRed": + self = Color(uiColor: .systemRed) + case "systemBlue": + self = Color(uiColor: .systemBlue) + case "systemGreen": + self = Color(uiColor: .systemGreen) + case "systemYellow": + self = Color(uiColor: .systemYellow) + case "systemPurple": + self = Color(uiColor: .systemPurple) + case "systemOrange": + self = Color(uiColor: .systemOrange) + case "systemPink": + self = Color(uiColor: .systemPink) + case "systemIndigo": + self = Color(uiColor: .systemIndigo) + default: + self = Color(uiColor: .systemGray) + } + } +} diff --git a/Coinly/Features/Accounts/AccountCardView.swift b/Coinly/Features/Accounts/AccountCardView.swift new file mode 100644 index 0000000..a1c31ee --- /dev/null +++ b/Coinly/Features/Accounts/AccountCardView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct AccountCardView: View { + let account: AccountModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: account.icon) + .font(.title2) + Text(account.name) + .font(.headline) + if account.isDefault { + Image(systemName: "star.fill") + .foregroundColor(.yellow) + } + Spacer() + if !account.isActive { + Text("Inactive") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Text(AppCurrencyFormatter.format(account.balance, currency: account.currency)) + .font(.title) + .bold() + + Text(account.type.rawValue) + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(account.type.color.opacity(0.1)) + .cornerRadius(12) + } +} + +#Preview { + AccountCardView(account: AccountModel.sampleData) + .padding() +} diff --git a/Coinly/Features/Accounts/AccountDetailView.swift b/Coinly/Features/Accounts/AccountDetailView.swift index 44fe8e0..88a27e4 100644 --- a/Coinly/Features/Accounts/AccountDetailView.swift +++ b/Coinly/Features/Accounts/AccountDetailView.swift @@ -1,101 +1,85 @@ import SwiftUI struct AccountDetailView: View { - @ObservedObject var account: AccountModel - let onDelete: () -> Void - + @EnvironmentObject private var accountsStore: AccountsStore + @EnvironmentObject private var transactionsStore: TransactionsStore @Environment(\.dismiss) private var dismiss - @State private var showingEditSheet = false - @State private var showingDeleteAlert = false + + let account: AccountModel + @State private var showingEditAccount = false var body: some View { List { - Section("Account Details") { - HStack { - Text("Balance") - Spacer() - Text(account.formattedBalance) - .foregroundColor(.secondary) - } + Section { + AccountCardView(account: account) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + + Section("Recent Transactions") { + let transactions = transactionsStore.filterTransactions( + filter: TransactionFilter(accountId: account.id) + ) - if let limit = account.creditLimit { - HStack { - Text("Credit Limit") - Spacer() - Text(limit.formatAsCurrency()) - .foregroundColor(.secondary) + if transactions.isEmpty { + Text("No transactions yet") + .foregroundColor(.secondary) + } else { + ForEach(transactions.prefix(5)) { transaction in + TransactionRowView(transaction: transaction) } - if let available = account.availableCredit { - HStack { - Text("Available Credit") - Spacer() - Text(available.formatAsCurrency()) - .foregroundColor(.secondary) - } + NavigationLink { + TransactionsView( + title: "\(account.name) Transactions", + filter: TransactionFilter(accountId: account.id) + ) + } label: { + Text("See All Transactions") } } - - HStack { - Text("Currency") - Spacer() - Text(account.currency.rawValue) - .foregroundColor(.secondary) + } + + if !account.isDefault { + Section { + Button(role: .destructive) { + accountsStore.deleteAccount(account) + dismiss() + } label: { + Label("Delete Account", systemImage: "trash") + } } } } .navigationTitle(account.name) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Menu { - Button { - showingEditSheet = true - } label: { - Label("Edit", systemImage: "pencil") - } - - Button(role: .destructive) { - showingDeleteAlert = true - } label: { - Label("Delete", systemImage: "trash") - } - } label: { - Image(systemName: "ellipsis.circle") + Button("Edit") { + showingEditAccount = true } } } - .sheet(isPresented: $showingEditSheet) { + .sheet(isPresented: $showingEditAccount) { NavigationView { - AddAccountView { updatedAccount in - account.name = updatedAccount.name - account.type = updatedAccount.type - account.balance = updatedAccount.balance - account.currency = updatedAccount.currency - account.creditLimit = updatedAccount.creditLimit - dismiss() - } + EditAccountView(account: account) } } - .alert("Delete Account", isPresented: $showingDeleteAlert) { - Button("Cancel", role: .cancel) { } - Button("Delete", role: .destructive) { - onDelete() - dismiss() - } - } message: { - Text("Are you sure you want to delete this account? This action cannot be undone.") - } } } -struct AccountDetailView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - AccountDetailView( - account: AccountModel.sampleData[0], - onDelete: {} - ) - } +#Preview { + NavigationView { + AccountDetailView(account: AccountModel( + name: "Sample Account", + balance: 1000, + type: .cash, + icon: "creditcard", + isDefault: true, + currency: .usd + )) + .environmentObject(AccountsStore.shared) + .environmentObject(TransactionsStore.shared) + .environmentObject(CategoryStore.shared) .environmentObject(AppSettings.shared) } } diff --git a/Coinly/Features/Accounts/AccountListView.swift b/Coinly/Features/Accounts/AccountListView.swift index 55e9f18..5457ff8 100644 --- a/Coinly/Features/Accounts/AccountListView.swift +++ b/Coinly/Features/Accounts/AccountListView.swift @@ -1,51 +1,20 @@ import SwiftUI struct AccountListView: View { - @ObservedObject var store: AccountsStore - @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var accountsStore: AccountsStore @State private var showingAddAccount = false var body: some View { List { - ForEach(AccountType.allCases, id: \.self) { type in - Section { - let typeAccounts = store.getAccounts(of: type) - if typeAccounts.isEmpty { - Button { - showingAddAccount = true - } label: { - HStack { - Image(systemName: "plus.circle.fill") - .foregroundColor(.accentColor) - Text("Add \(type.rawValue)") - .foregroundColor(.accentColor) - } - } - } else { - ForEach(typeAccounts) { account in - AccountRowView(account: account) - .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) - } - } - } header: { - HStack { - Image(systemName: type.icon) - .foregroundColor(type.color) - Text(type.rawValue) - } + ForEach(accountsStore.accounts) { account in + NavigationLink(destination: AccountDetailView(account: account)) { + AccountRowView(account: account) } } + .onDelete(perform: deleteAccount) } - .listStyle(InsetGroupedListStyle()) .navigationTitle("Accounts") - .navigationBarTitleDisplayMode(.large) .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Close") { - dismiss() - } - } - ToolbarItem(placement: .navigationBarTrailing) { Button { showingAddAccount = true @@ -56,19 +25,22 @@ struct AccountListView: View { } .sheet(isPresented: $showingAddAccount) { NavigationView { - AddAccountView { newAccount in - store.addAccount(newAccount) - } + AddAccountView() } } } + + private func deleteAccount(at offsets: IndexSet) { + offsets.forEach { index in + accountsStore.deleteAccount(accountsStore.accounts[index]) + } + } } -struct AccountListView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - AccountListView(store: AccountsStore()) - } - .environmentObject(AppSettings.shared) +#Preview { + NavigationView { + AccountListView() + .environmentObject(AccountsStore.shared) + .environmentObject(AppSettings.shared) } } diff --git a/Coinly/Features/Accounts/AccountPickerView.swift b/Coinly/Features/Accounts/AccountPickerView.swift new file mode 100644 index 0000000..75ea6df --- /dev/null +++ b/Coinly/Features/Accounts/AccountPickerView.swift @@ -0,0 +1,54 @@ +// +// AccountPickerView.swift +// Coinly +// +// Created by Vadym Samoilenko on 03/03/2025. +// + + +import SwiftUI + +struct AccountPickerView: View { + @EnvironmentObject private var accountsStore: AccountsStore + @Environment(\.dismiss) private var dismiss + @State private var searchText = "" + + let onSelect: (AccountModel) -> Void + + private var filteredAccounts: [AccountModel] { + if searchText.isEmpty { + return accountsStore.accounts + } + return accountsStore.accounts.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } + + var body: some View { + List { + ForEach(filteredAccounts) { account in + Button { + onSelect(account) + dismiss() + } label: { + AccountRowView(account: account) + } + } + } + .navigationTitle("Select Account") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: "Search accounts") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } +} + +#Preview { + NavigationView { + AccountPickerView { _ in } + .environmentObject(AccountsStore.shared) + } +} \ No newline at end of file diff --git a/Coinly/UI/Components/AccountRowView.swift b/Coinly/Features/Accounts/AccountRowView.swift similarity index 57% rename from Coinly/UI/Components/AccountRowView.swift rename to Coinly/Features/Accounts/AccountRowView.swift index 2f7a0d2..a823377 100644 --- a/Coinly/UI/Components/AccountRowView.swift +++ b/Coinly/Features/Accounts/AccountRowView.swift @@ -5,44 +5,40 @@ struct AccountRowView: View { var body: some View { HStack(spacing: 16) { - Image(systemName: account.type.icon) + Image(systemName: account.icon) .font(.title2) .foregroundColor(.white) .frame(width: 40, height: 40) .background(account.type.color) - .clipShape(RoundedRectangle(cornerRadius: 10)) + .cornerRadius(8) VStack(alignment: .leading, spacing: 4) { Text(account.name) .font(.headline) + Text(account.type.rawValue) - .font(.subheadline) + .font(.caption) .foregroundColor(.secondary) } Spacer() VStack(alignment: .trailing, spacing: 4) { - Text(account.formattedBalance) + Text(AppCurrencyFormatter.format(account.balance, currency: account.currency)) .font(.headline) - if let availableCredit = account.availableCredit { - Text("Available: \(availableCredit.formatAsCurrency())") + if account.isDefault { + Text("Default") .font(.caption) .foregroundColor(.secondary) } } } - .padding() - .background(Color(uiColor: .secondarySystemBackground)) - .cornerRadius(12) + .opacity(account.isActive ? 1 : 0.5) } } -struct AccountRowView_Previews: PreviewProvider { - static var previews: some View { - AccountRowView(account: AccountModel.sampleData[0]) - .previewLayout(.sizeThatFits) - .padding() - } +#Preview { + AccountRowView(account: AccountModel.sampleData) + .padding() } diff --git a/Coinly/Features/Accounts/AccountsListView.swift b/Coinly/Features/Accounts/AccountsListView.swift deleted file mode 100644 index 73b3076..0000000 --- a/Coinly/Features/Accounts/AccountsListView.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// AccountsListView.swift -// Coinly -// -// Created by Vadym Samoilenko on 02/03/2025. -// - - -import SwiftUI - -struct AccountsListView: View { - @Environment(\.dismiss) private var dismiss - @ObservedObject var store: AccountsStore - @State private var showingAddAccount = false - - var body: some View { - NavigationView { - List { - ForEach(AccountType.allCases, id: \.self) { type in - Section(type.rawValue) { - ForEach(store.getAccounts(of: type)) { account in - AccountCardView(account: account) - .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) - } - } - } - } - .listStyle(InsetGroupedListStyle()) - .navigationTitle("Accounts") - .navigationBarTitleDisplayMode(.large) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Close") { - dismiss() - } - } - - ToolbarItem(placement: .navigationBarTrailing) { - Button { - showingAddAccount = true - } label: { - Image(systemName: "plus") - } - } - } - .background(Color(uiColor: .systemGroupedBackground)) - } - } -} - -struct AccountsListView_Previews: PreviewProvider { - static var previews: some View { - Group { - AccountsListView(store: AccountsStore()) - .preferredColorScheme(.light) - - AccountsListView(store: AccountsStore()) - .preferredColorScheme(.dark) - } - .environmentObject(AppSettings.shared) - } -} diff --git a/Coinly/Features/Accounts/AddAccountView.swift b/Coinly/Features/Accounts/AddAccountView.swift index 88c17a2..86e5246 100644 --- a/Coinly/Features/Accounts/AddAccountView.swift +++ b/Coinly/Features/Accounts/AddAccountView.swift @@ -1,33 +1,41 @@ +// Path: Features/Accounts/AddAccountView.swift + import SwiftUI struct AddAccountView: View { - @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var accountsStore: AccountsStore @EnvironmentObject private var settings: AppSettings + @Environment(\.dismiss) private var dismiss - let onSave: (AccountModel) -> Void - - @State private var name: String = "" - @State private var type: AccountType = .wallet - @State private var balance: String = "" + @State private var name = "" + @State private var type: AccountType = .cash + @State private var balance: Double = 0 @State private var currency: AppSettings.Currency = .usd - @State private var creditLimit: String = "" + @State private var icon = "creditcard" + @State private var isDefault = false + @State private var showingTypeSelector = false var body: some View { Form { - Section("Basic Information") { + Section { TextField("Account Name", text: $name) - Picker("Type", selection: $type) { - ForEach(AccountType.allCases, id: \.self) { type in - Text(type.rawValue).tag(type) + Button { + showingTypeSelector = true + } label: { + HStack { + Text("Type") + Spacer() + HStack { + Image(systemName: type.icon) + .foregroundColor(type.color) + Text(type.rawValue) + .foregroundColor(.secondary) + } } } - HStack { - Text(currency.symbol) - TextField("Balance", text: $balance) - .keyboardType(.decimalPad) - } + CurrencyField("Initial Balance", value: $balance, currency: currency) Picker("Currency", selection: $currency) { ForEach(AppSettings.Currency.allCases, id: \.self) { currency in @@ -36,52 +44,54 @@ struct AddAccountView: View { } } - if type == .creditCard { - Section("Credit Card Details") { - HStack { - Text(currency.symbol) - TextField("Credit Limit", text: $creditLimit) - .keyboardType(.decimalPad) - } - } + Section { + Toggle("Set as Default", isOn: $isDefault) } } .navigationTitle("New Account") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { + ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { dismiss() } } - ToolbarItem(placement: .confirmationAction) { + ToolbarItem(placement: .navigationBarTrailing) { Button("Add") { - guard let balanceValue = Double(balance) else { return } - let limitValue = Double(creditLimit) - let account = AccountModel( name: name, + balance: balance, type: type, - currency: currency, - balance: balanceValue, - creditLimit: limitValue + icon: type.icon, // Используем иконку из типа аккаунта + isDefault: isDefault, + currency: currency ) - - onSave(account) + accountsStore.addAccount(account) dismiss() } - .disabled(name.isEmpty || balance.isEmpty) + .disabled(name.isEmpty) } } + .sheet(isPresented: $showingTypeSelector) { + NavigationView { + SelectAccountTypeView { selectedType in + type = selectedType + icon = selectedType.icon // Обновляем иконку при выборе типа + } + } + } + .onAppear { + // Используем текущую валюту из настроек + currency = settings.selectedCurrency + } } } -struct AddAccountView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - AddAccountView { _ in } - } - .environmentObject(AppSettings.shared) +#Preview { + NavigationView { + AddAccountView() + .environmentObject(AccountsStore.shared) + .environmentObject(AppSettings.shared) } } diff --git a/Coinly/Features/Accounts/EditAccountView.swift b/Coinly/Features/Accounts/EditAccountView.swift new file mode 100644 index 0000000..e6c0b61 --- /dev/null +++ b/Coinly/Features/Accounts/EditAccountView.swift @@ -0,0 +1,93 @@ +import SwiftUI + +struct EditAccountView: View { + @EnvironmentObject private var accountsStore: AccountsStore + @Environment(\.dismiss) private var dismiss + + let account: AccountModel + + @State private var name: String + @State private var type: AccountType + @State private var balance: Double + @State private var currency: AppSettings.Currency + @State private var icon: String + @State private var isDefault: Bool + @State private var isActive: Bool + @State private var isArchived: Bool + + init(account: AccountModel) { + self.account = account + self._name = State(initialValue: account.name) + self._type = State(initialValue: account.type) + self._balance = State(initialValue: account.balance) + self._currency = State(initialValue: account.currency) + self._icon = State(initialValue: account.icon) + self._isDefault = State(initialValue: account.isDefault) + self._isActive = State(initialValue: account.isActive) + self._isArchived = State(initialValue: account.isArchived) + } + + var body: some View { + Form { + Section { + TextField("Account Name", text: $name) + + Picker("Type", selection: $type) { + ForEach(AccountType.allCases, id: \.self) { type in + Text(type.rawValue).tag(type) + } + } + + CurrencyField("Balance", value: $balance, currency: currency) + + Picker("Currency", selection: $currency) { + ForEach(AppSettings.Currency.allCases, id: \.self) { currency in + Text(currency.rawValue).tag(currency) + } + } + } + + Section { + Toggle("Active", isOn: $isActive) + Toggle("Set as Default", isOn: $isDefault) + Toggle("Archive", isOn: $isArchived) + } + } + .navigationTitle("Edit Account") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + let updatedAccount = AccountModel( + id: account.id, + name: name, + balance: balance, + type: type, + icon: icon, + isDefault: isDefault, + currency: currency, + isActive: isActive, + isArchived: isArchived + ) + accountsStore.updateAccount(updatedAccount) + dismiss() + } + .disabled(name.isEmpty) + } + } + } +} + +#Preview { + NavigationView { + EditAccountView(account: AccountModel.sampleData) + .environmentObject(AccountsStore.shared) + .environmentObject(AppSettings.shared) + } +} diff --git a/Coinly/Features/Accounts/SelectAccountTypeView.swift b/Coinly/Features/Accounts/SelectAccountTypeView.swift index a1b0e82..85dbef2 100644 --- a/Coinly/Features/Accounts/SelectAccountTypeView.swift +++ b/Coinly/Features/Accounts/SelectAccountTypeView.swift @@ -1,10 +1,4 @@ -// -// SelectAccountTypeView.swift -// Coinly -// -// Created by Vadym Samoilenko on 02/03/2025. -// - +// Path: Features/Accounts/SelectAccountTypeView.swift import SwiftUI @@ -13,43 +7,41 @@ struct SelectAccountTypeView: View { let onSelect: (AccountType) -> Void var body: some View { - NavigationView { - List { - ForEach(AccountType.allCases, id: \.self) { type in - Button { - onSelect(type) - dismiss() - } label: { - HStack { - Image(systemName: type.icon) - .foregroundColor(type.color) - .frame(width: 30) + List { + ForEach(AccountType.allCases, id: \.self) { type in + Button { + onSelect(type) + dismiss() + } label: { + HStack { + Image(systemName: type.icon) + .foregroundColor(type.color) + .frame(width: 30) + + VStack(alignment: .leading) { + Text(type.rawValue) + .font(.headline) + .foregroundColor(.primary) - VStack(alignment: .leading) { - Text(type.rawValue) - .font(.headline) - .foregroundColor(.primary) - - Text(descriptionFor(type)) - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .foregroundColor(Color(uiColor: .systemGray4)) + Text(descriptionFor(type)) + .font(.caption) + .foregroundColor(.secondary) } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(Color(uiColor: .systemGray4)) } } } - .navigationTitle("Select Account Type") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } + } + .navigationTitle("Select Account Type") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() } } } @@ -58,15 +50,29 @@ struct SelectAccountTypeView: View { private func descriptionFor(_ type: AccountType) -> String { switch type { case .wallet: - return "Cash and physical money" + return "Digital wallet for everyday expenses" + case .cash: + return "Physical cash and money" case .bankAccount: return "Regular bank account" case .creditCard: return "Credit card with limit" + case .savings: + return "Long-term savings account" + case .investment: + return "Investment portfolio" case .deposit: - return "Savings and deposits" + return "Fixed term deposits" case .debt: return "Debts and loans" } } } + +#Preview { + NavigationView { + SelectAccountTypeView { type in + print("Selected type: \(type)") + } + } +} diff --git a/Coinly/Features/Budget/BudgetSettingsView.swift b/Coinly/Features/Budget/BudgetSettingsView.swift deleted file mode 100644 index f4070a7..0000000 --- a/Coinly/Features/Budget/BudgetSettingsView.swift +++ /dev/null @@ -1,125 +0,0 @@ -import SwiftUI - -struct BudgetSettingsView: View { - @ObservedObject var categoryStore: CategoryStore - @State private var selectedCategory: CategoryModel? - - var body: some View { - List { - Section { - NavigationLink { - MonthlyBudgetView(categoryStore: categoryStore) - } label: { - HStack { - Text("Monthly Budget") - Spacer() - Text("2000") - .foregroundColor(.secondary) - } - } - } - - Section("Category Budgets") { - ForEach(categoryStore.getCategories(of: .expense)) { category in - HStack { - CategoryRowView(category: category) - - Button { - selectedCategory = category - } label: { - if let budget = categoryStore.getBudget(for: category.id) { - Text(budget.amount.formatAsCurrency()) - .foregroundColor(.secondary) - } else { - Text("Set Budget") - .foregroundColor(.accentColor) - } - } - } - } - } - } - .navigationTitle("Budget Settings") - .sheet(item: $selectedCategory) { category in - NavigationView { - CategoryBudgetView( - category: category, - budget: categoryStore.getBudget(for: category.id) - ) { budget in - if categoryStore.getBudget(for: category.id) != nil { - categoryStore.updateBudget(budget) - } else { - categoryStore.addBudget(budget) - } - } - } - } - } -} - -struct CategoryBudgetView: View { - @Environment(\.dismiss) private var dismiss - let category: CategoryModel - let budget: BudgetModel? - let onSave: (BudgetModel) -> Void - - @State private var amount: String = "" - @State private var period: BudgetModel.BudgetPeriod = .monthly - @State private var isRecurring = true - - var body: some View { - Form { - Section { - CategoryRowView(category: category) - .listRowBackground(Color.clear) - } - - Section("Budget Details") { - HStack { - Text(AppSettings.shared.selectedCurrency.symbol) - TextField("Amount", text: $amount) - .keyboardType(.decimalPad) - } - - Picker("Period", selection: $period) { - ForEach(BudgetModel.BudgetPeriod.allCases, id: \.self) { period in - Text(period.rawValue).tag(period) - } - } - - Toggle("Recurring", isOn: $isRecurring) - } - } - .navigationTitle("Category Budget") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - } - - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - guard let amountValue = Double(amount) else { return } - let budget = BudgetModel( - categoryId: category.id, - amount: amountValue, - period: period, - isRecurring: isRecurring - ) - onSave(budget) - dismiss() - } - .disabled(amount.isEmpty) - } - } - .onAppear { - if let existingBudget = budget { - amount = existingBudget.amount.description - period = existingBudget.period - isRecurring = existingBudget.isRecurring - } - } - } -} \ No newline at end of file diff --git a/Coinly/Features/Budget/Models/BudgetModel.swift b/Coinly/Features/Budget/Models/BudgetModel.swift new file mode 100644 index 0000000..5f27301 --- /dev/null +++ b/Coinly/Features/Budget/Models/BudgetModel.swift @@ -0,0 +1,20 @@ +// +// BudgetModel.swift +// Coinly +// +// Created by Vadym Samoilenko on 03/03/2025. +// + + +import Foundation + +struct BudgetModel: Codable, Identifiable { + var id: String { categoryId } + let categoryId: String + var amount: Double + + init(categoryId: String, amount: Double) { + self.categoryId = categoryId + self.amount = amount + } +} \ No newline at end of file diff --git a/Coinly/Features/Budget/Views/BudgetProgressView.swift b/Coinly/Features/Budget/Views/BudgetProgressView.swift new file mode 100644 index 0000000..46ff1df --- /dev/null +++ b/Coinly/Features/Budget/Views/BudgetProgressView.swift @@ -0,0 +1,54 @@ +// +// BudgetProgressView.swift +// Coinly +// +// Created by Vadym Samoilenko on 03/03/2025. +// + + +import SwiftUI + +struct BudgetProgressView: View { + let spent: Double + let total: Double + let color: Color + + private var progress: Double { + guard total > 0 else { return 0 } + return min(spent / total, 1.0) + } + + private var progressColor: Color { + if progress >= 1.0 { + return .red + } else if progress >= 0.8 { + return .orange + } else { + return color + } + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Rectangle() + .fill(Color(uiColor: .systemGray5)) + + Rectangle() + .fill(progressColor) + .frame(width: geometry.size.width * progress) + } + } + .frame(height: 4) + .cornerRadius(2) + } +} + +#Preview { + VStack(spacing: 20) { + BudgetProgressView(spent: 80, total: 100, color: .blue) + BudgetProgressView(spent: 90, total: 100, color: .blue) + BudgetProgressView(spent: 100, total: 100, color: .blue) + } + .padding() +} \ No newline at end of file diff --git a/Coinly/Features/Budget/Views/BudgetSettingsView.swift b/Coinly/Features/Budget/Views/BudgetSettingsView.swift new file mode 100644 index 0000000..027a7a6 --- /dev/null +++ b/Coinly/Features/Budget/Views/BudgetSettingsView.swift @@ -0,0 +1,48 @@ +// +// BudgetSettingsView.swift +// Coinly +// +// Created by Vadym Samoilenko on 03/03/2025. +// + + +import SwiftUI + +struct BudgetSettingsView: View { + @EnvironmentObject private var settings: AppSettings + @State private var showingMonthlyBudget = false + + var body: some View { + List { + Section { + NavigationLink { + MonthlyBudgetSettingsView() + } label: { + HStack { + Text("Monthly Budget") + Spacer() + Text(AppCurrencyFormatter.format(settings.monthlyBudget)) + .foregroundColor(.secondary) + } + } + } + + Section { + NavigationLink { + CategoryBudgetSettingsView() + } label: { + Text("Category Budgets") + } + } + } + .navigationTitle("Budget Settings") + } +} + +#Preview { + NavigationView { + BudgetSettingsView() + .environmentObject(AppSettings.shared) + .environmentObject(CategoryStore.shared) + } +} \ No newline at end of file diff --git a/Coinly/Features/Budget/Views/CategoryBudgetEditView.swift b/Coinly/Features/Budget/Views/CategoryBudgetEditView.swift new file mode 100644 index 0000000..efbffbd --- /dev/null +++ b/Coinly/Features/Budget/Views/CategoryBudgetEditView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct CategoryBudgetEditView: View { + @EnvironmentObject private var categoryStore: CategoryStore + @EnvironmentObject private var settings: AppSettings + @Environment(\.dismiss) private var dismiss + + let category: CategoryModel + @State private var budget: Double + + init(category: CategoryModel) { + self.category = category + self._budget = State(initialValue: CategoryStore.shared.getBudget(for: category.id)?.amount ?? 0) + } + + var body: some View { + Form { + Section { + CurrencyField( + "Budget Amount", + value: $budget, + currency: settings.selectedCurrency + ) + } footer: { + Text("Set monthly budget for \(category.name)") + } + } + .navigationTitle(category.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + categoryStore.setBudget(budget, for: category.id) + dismiss() + } + } + } + } +} + +#Preview { + NavigationView { + CategoryBudgetEditView(category: CategoryModel.sampleData) + .environmentObject(CategoryStore.shared) + .environmentObject(AppSettings.shared) + } +} diff --git a/Coinly/Features/Budget/Views/CategoryBudgetRowView.swift b/Coinly/Features/Budget/Views/CategoryBudgetRowView.swift new file mode 100644 index 0000000..f9533de --- /dev/null +++ b/Coinly/Features/Budget/Views/CategoryBudgetRowView.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct CategoryBudgetRowView: View { + @EnvironmentObject private var categoryStore: CategoryStore + let category: CategoryModel + + private var budget: BudgetModel? { + categoryStore.getBudget(for: category.id) + } + + var body: some View { + HStack { + CategoryRowView(category: category) + + Spacer() + + if let budget = budget { + Text(AppCurrencyFormatter.format(budget.amount)) + .foregroundColor(.secondary) + } else { + Text("Not Set") + .foregroundColor(.secondary) + } + } + } +} + +#Preview { + CategoryBudgetRowView(category: CategoryModel.sampleData) + .environmentObject(CategoryStore.shared) + .padding() +} diff --git a/Coinly/Features/Budget/Views/CategoryBudgetSettingsView.swift b/Coinly/Features/Budget/Views/CategoryBudgetSettingsView.swift new file mode 100644 index 0000000..f0fe248 --- /dev/null +++ b/Coinly/Features/Budget/Views/CategoryBudgetSettingsView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct CategoryBudgetSettingsView: View { + @EnvironmentObject private var categoryStore: CategoryStore + @EnvironmentObject private var settings: AppSettings + + var body: some View { + List { + ForEach(categoryStore.getCategories(of: .expense)) { category in + NavigationLink { + CategoryBudgetEditView(category: category) + } label: { + CategoryBudgetRowView(category: category) + } + } + } + .navigationTitle("Category Budgets") + } +} + +#Preview { + NavigationView { + CategoryBudgetSettingsView() + .environmentObject(CategoryStore.shared) + .environmentObject(AppSettings.shared) + } +} diff --git a/Coinly/Features/Budget/Views/MonthlyBudgetSettingsView.swift b/Coinly/Features/Budget/Views/MonthlyBudgetSettingsView.swift new file mode 100644 index 0000000..c70d12c --- /dev/null +++ b/Coinly/Features/Budget/Views/MonthlyBudgetSettingsView.swift @@ -0,0 +1,47 @@ +// +// MonthlyBudgetSettingsView.swift +// Coinly +// +// Created by Vadym Samoilenko on 03/03/2025. +// + + +import SwiftUI + +struct MonthlyBudgetSettingsView: View { + @EnvironmentObject private var settings: AppSettings + @State private var monthlyBudget: Double + + init() { + _monthlyBudget = State(initialValue: AppSettings.shared.monthlyBudget) + } + + var body: some View { + Form { + Section { + CurrencyField( + "Monthly Budget", + value: Binding( + get: { monthlyBudget }, + set: { + monthlyBudget = $0 + settings.monthlyBudget = $0 + } + ), + currency: settings.selectedCurrency + ) + } footer: { + Text("Set your monthly budget to track your spending") + } + } + .navigationTitle("Monthly Budget") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + NavigationView { + MonthlyBudgetSettingsView() + .environmentObject(AppSettings.shared) + } +} \ No newline at end of file diff --git a/Coinly/Features/Categories/AddCategoryView.swift b/Coinly/Features/Categories/AddCategoryView.swift index 62c0a31..dd6c0e0 100644 --- a/Coinly/Features/Categories/AddCategoryView.swift +++ b/Coinly/Features/Categories/AddCategoryView.swift @@ -7,7 +7,7 @@ struct AddCategoryView: View { @State private var name = "" @State private var selectedIcon = "cart.fill" - @State private var selectedColor = "blue" + @State private var selectedColor = "systemBlue" private let icons = [ "cart.fill", "car.fill", "house.fill", "creditcard.fill", @@ -18,8 +18,8 @@ struct AddCategoryView: View { ] private let colors = [ - "red", "blue", "green", "yellow", - "purple", "orange", "pink", "indigo" + "systemRed", "systemBlue", "systemGreen", "systemYellow", + "systemPurple", "systemOrange", "systemPink", "systemIndigo" ] var body: some View { @@ -31,15 +31,16 @@ struct AddCategoryView: View { Section("Icon") { LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: 16) { ForEach(icons, id: \.self) { icon in - Image(systemName: icon) - .font(.title2) - .frame(width: 44, height: 44) - .background(selectedIcon == icon ? Color.accentColor : Color.clear) - .foregroundColor(selectedIcon == icon ? .white : .primary) - .cornerRadius(8) - .onTapGesture { - selectedIcon = icon - } + Button { + selectedIcon = icon + } label: { + Image(systemName: icon) + .font(.title2) + .frame(width: 44, height: 44) + .background(selectedIcon == icon ? Color.accentColor : Color.clear) + .foregroundColor(selectedIcon == icon ? .white : .primary) + .cornerRadius(8) + } } } } @@ -47,18 +48,19 @@ struct AddCategoryView: View { Section("Color") { LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 8), spacing: 16) { ForEach(colors, id: \.self) { color in - Circle() - .fill(CategoryModel(name: "", icon: "", color: color, type: .expense).iconColor()) - .frame(width: 32, height: 32) - .overlay { - if selectedColor == color { - Image(systemName: "checkmark") - .foregroundColor(.white) + Button { + selectedColor = color + } label: { + Circle() + .fill(Color(color)) + .frame(width: 32, height: 32) + .overlay { + if selectedColor == color { + Image(systemName: "checkmark") + .foregroundColor(.white) + } } - } - .onTapGesture { - selectedColor = color - } + } } } } @@ -87,4 +89,4 @@ struct AddCategoryView: View { } } } -} \ No newline at end of file +} diff --git a/Coinly/Features/Categories/CategoryListView.swift b/Coinly/Features/Categories/CategoryListView.swift index 5cc59b7..4df1719 100644 --- a/Coinly/Features/Categories/CategoryListView.swift +++ b/Coinly/Features/Categories/CategoryListView.swift @@ -1,23 +1,15 @@ import SwiftUI struct CategoryListView: View { - @ObservedObject var categoryStore: CategoryStore + @EnvironmentObject private var categoryStore: CategoryStore @State private var showingAddCategory = false - @State private var selectedType: TransactionType = .expense + let type: TransactionType var body: some View { List { - Picker("Type", selection: $selectedType) { - Text("Expenses").tag(TransactionType.expense) - Text("Income").tag(TransactionType.income) - } - .pickerStyle(.segmented) - .listRowBackground(Color.clear) - .padding(.vertical, 8) - - ForEach(categoryStore.getCategories(of: selectedType)) { category in + ForEach(categoryStore.getCategories(of: type)) { category in CategoryRowView(category: category) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { + .swipeActions(edge: .trailing, allowsFullSwipe: false) { if !category.isDefault { Button(role: .destructive) { categoryStore.deleteCategory(category) @@ -25,17 +17,10 @@ struct CategoryListView: View { Label("Delete", systemImage: "trash") } } - - Button { - // Edit category - } label: { - Label("Edit", systemImage: "pencil") - } - .tint(.blue) } } } - .navigationTitle("Categories") + .navigationTitle(type == .income ? "Income Categories" : "Expense Categories") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { @@ -47,45 +32,17 @@ struct CategoryListView: View { } .sheet(isPresented: $showingAddCategory) { NavigationView { - AddCategoryView(type: selectedType) { newCategory in - categoryStore.addCategory(newCategory) + AddCategoryView(type: type) { category in + categoryStore.addCategory(category) } } } } } -struct CategoryRowView: View { - let category: CategoryModel - - var body: some View { - HStack(spacing: 16) { - Image(systemName: category.icon) - .font(.title2) - .foregroundColor(.white) - .frame(width: 36, height: 36) - .background(category.iconColor()) - .cornerRadius(8) - - VStack(alignment: .leading, spacing: 4) { - Text(category.name) - .font(.headline) - - if category.isDefault { - Text("Default") - .font(.caption) - .foregroundColor(.secondary) - } - } - - Spacer() - - if let budget = CategoryStore.shared.getBudget(for: category.id) { - Text(budget.amount.formatAsCurrency()) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - .padding(.vertical, 8) +#Preview { + NavigationView { + CategoryListView(type: .expense) + .environmentObject(CategoryStore.shared) } -} \ No newline at end of file +} diff --git a/Coinly/Features/Dashboard/DashboardView.swift b/Coinly/Features/Dashboard/DashboardView.swift index 735e3e9..985e915 100644 --- a/Coinly/Features/Dashboard/DashboardView.swift +++ b/Coinly/Features/Dashboard/DashboardView.swift @@ -1,278 +1,198 @@ import SwiftUI -struct BudgetProgress: View { - let spent: Double - let total: Double - let color: Color +struct DashboardView: View { + // MARK: - Environment + @EnvironmentObject private var store: TransactionsStore + @EnvironmentObject private var accountsStore: AccountsStore + @EnvironmentObject private var categoryStore: CategoryStore + @EnvironmentObject private var settings: AppSettings - private var percentage: Double { - guard total > 0 else { return 0 } - return min(spent / total, 1.0) + // MARK: - State + @State private var showingAddTransaction = false + @State private var selectedPeriod: TransactionFilter.Period = .month + + // MARK: - Computed Properties + private var filteredTransactions: [TransactionModel] { + store.filterTransactions(filter: TransactionFilter(period: selectedPeriod)) } + 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 expenseCategories: [(CategoryModel, Double)] { + categoryStore.getCategories(of: .expense).compactMap { category in + let amount = filteredTransactions + .filter { $0.type == .expense && $0.categoryId == category.id } + .reduce(0) { $0 + $1.amount } + return amount > 0 ? (category, amount) : nil + } + .sorted { $0.1 > $1.1 } + } + + // MARK: - Body 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) + ScrollView { + VStack(spacing: 20) { + balanceCard + periodSelector + if !expenseCategories.isEmpty { expenseCategoriesSection } + if !filteredTransactions.isEmpty { recentTransactionsSection } + } + .padding() + } + .navigationTitle("Dashboard") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showingAddTransaction = true + } label: { + Image(systemName: "plus") } } - .frame(height: 6) + } + .sheet(isPresented: $showingAddTransaction) { + NavigationView { + AddTransactionView { transaction in + store.addTransaction(transaction) + } + } + } + } + + // MARK: - Views + private var balanceCard: some View { + VStack(spacing: 8) { + Text("Total Balance") + .font(.headline) + .foregroundColor(.secondary) + Text(AppCurrencyFormatter.format(accountsStore.totalBalance())) + .font(.system(size: 34, weight: .bold)) + + HStack(spacing: 20) { + VStack(spacing: 4) { + Text("Income") + .font(.subheadline) + .foregroundColor(.secondary) + Text(AppCurrencyFormatter.format(totalIncome)) + .font(.headline) + .foregroundColor(.green) + } + + VStack(spacing: 4) { + Text("Expenses") + .font(.subheadline) + .foregroundColor(.secondary) + Text(AppCurrencyFormatter.format(totalExpenses)) + .font(.headline) + .foregroundColor(.red) + } + } + .padding(.top, 8) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(12) + } + + private var periodSelector: some View { + Picker("Period", selection: $selectedPeriod) { + ForEach(TransactionFilter.Period.allCases, id: \.self) { period in + Text(period.rawValue).tag(period) + } + } + .pickerStyle(.segmented) + } + + private var expenseCategoriesSection: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Top Expenses") + .font(.headline) + + ForEach(expenseCategories, id: \.0.id) { category, amount in + VStack(spacing: 8) { + HStack { + CategoryRowView(category: category) + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Text(AppCurrencyFormatter.format(amount)) + .font(.headline) + + let percentage = totalExpenses > 0 ? (amount / totalExpenses) * 100 : 0 + Text(String(format: "%.1f%%", percentage)) + .font(.caption) + .foregroundColor(.secondary) + } + } + + if let budget = categoryStore.getBudget(for: category.id) { + BudgetProgressView( + spent: amount, + total: budget.amount, + color: category.iconColor() + ) + } + } + + if category.id != expenseCategories.last?.0.id { + Divider() + } + } + } + .padding() + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(12) + } + + private var recentTransactionsSection: some View { + VStack(alignment: .leading, spacing: 16) { HStack { - Text("\(Int(percentage * 100))% spent") - .font(.caption) - .foregroundColor(.secondary) + Text("Recent Transactions") + .font(.headline) Spacer() - Text("\(spent.formatAsCurrency()) of \(total.formatAsCurrency())") - .font(.caption) - .foregroundColor(.secondary) + NavigationLink("See All") { + TransactionsView() + } + .font(.subheadline) + } + + ForEach(Array(filteredTransactions.prefix(5))) { transaction in + VStack { + TransactionRowView(transaction: transaction) + + if transaction.id != filteredTransactions.prefix(5).last?.id { + Divider() + } + } } } + .padding() + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(12) } } -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 adjustedBalance: Double { - accountsStore.accounts.reduce(0) { total, account in - total + ((!includeCreditCards && account.type == .creditCard) ? 0 : account.balance) - } - } - - private var thisMonthTransactions: [TransactionModel] { - let calendar = Calendar.current - return store.transactions.filter { transaction in - calendar.isDate(transaction.date, equalTo: Date(), toGranularity: .month) - } - } - - private var monthlyIncome: Double { - thisMonthTransactions - .filter { !$0.isExpense } - .reduce(0) { $0 + $1.amountInCurrentCurrency() } - } - - private var monthlyExpenses: Double { - thisMonthTransactions - .filter { $0.isExpense } - .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) { - 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(adjustedBalance.formatAsCurrency()) - .font(.system(size: 34, weight: .bold)) - - HStack(spacing: 20) { - // Income - HStack { - Circle() - .fill(Color(uiColor: .systemGreen)) - .frame(width: 8, height: 8) - Text("↑ \(monthlyIncome.formatAsCurrency())") - .font(.caption) - .foregroundColor(Color(uiColor: .systemGreen)) - } - - // Expenses - HStack { - Circle() - .fill(Color(uiColor: .systemRed)) - .frame(width: 8, height: 8) - Text("↓ \(monthlyExpenses.formatAsCurrency())") - .font(.caption) - .foregroundColor(Color(uiColor: .systemRed)) - } - } - } - .padding() - .background(Color(uiColor: .secondarySystemBackground)) - .cornerRadius(16) - - // Accounts Section - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Accounts") - .font(.title2) - .fontWeight(.bold) - - Spacer() - - Button { - showingAddAccount = true - } label: { - Text("Add Account") - .foregroundColor(.accentColor) - } - } - - ForEach(accountsStore.accounts) { account in - AccountRowView(account: account) - .onTapGesture { - selectedAccount = account - } - } - } - .padding(.horizontal) - - // Spending Analysis Section - if !categoryExpenses.isEmpty { - VStack(alignment: .leading, spacing: 16) { - 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) - } - } - .padding(.vertical) - } - .navigationTitle("Dashboard") - .sheet(isPresented: $showingAddAccount) { - NavigationView { - AddAccountView { newAccount in - accountsStore.addAccount(newAccount) - } - } - } - .sheet(item: $selectedAccount) { account in - NavigationView { - AccountDetailView( - account: account, - onDelete: { - accountsStore.deleteAccount(account) - selectedAccount = nil - } - ) - } - } - } -} - -struct DashboardView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - DashboardView( - store: TransactionsStore(), - accountsStore: AccountsStore() - ) +// MARK: - Preview +#Preview { + NavigationView { + DashboardView() + .environmentObject(TransactionsStore.shared) + .environmentObject(AccountsStore.shared) + .environmentObject(CategoryStore.shared) .environmentObject(AppSettings.shared) - } } } diff --git a/Coinly/Features/Models/AccountModel.swift b/Coinly/Features/Models/AccountModel.swift index 02bd8fc..ed0f29d 100644 --- a/Coinly/Features/Models/AccountModel.swift +++ b/Coinly/Features/Models/AccountModel.swift @@ -1,105 +1,90 @@ import Foundation -import SwiftUI -class AccountModel: ObservableObject, Identifiable { +class AccountModel: ObservableObject, Identifiable, Codable { let id: String @Published var name: String - @Published var type: AccountType - @Published var currency: AppSettings.Currency @Published var balance: Double + @Published var type: AccountType + @Published var icon: String + @Published var isDefault: Bool + @Published var currency: AppSettings.Currency @Published var isActive: Bool + @Published var isArchived: Bool - // Кредитная карта - @Published var creditLimit: Double? - @Published var interestRate: Double? - @Published var dueDate: Date? - @Published var minimumPayment: Double? + var balanceInDefaultCurrency: Double { + // TODO: Implement currency conversion + // Пока просто возвращаем баланс без конвертации + balance + } - // Депозит - @Published var depositEndDate: Date? - @Published var depositInterestRate: Double? - @Published var isAutoRenewable: Bool? + static let sampleData = AccountModel( + name: "Sample Account", + balance: 1000, + type: .cash, + icon: "creditcard", + isDefault: true, + currency: .usd, + isActive: true, + isArchived: false + ) - // Долг - @Published var creditorName: String? - @Published var debtInterestRate: Double? - @Published var paymentSchedule: [DebtPayment]? + enum CodingKeys: String, CodingKey { + case id + case name + case balance + case type + case icon + case isDefault + case currency + case isActive + case isArchived + } init( id: String = UUID().uuidString, name: String, - type: AccountType, - currency: AppSettings.Currency, balance: Double = 0, + type: AccountType = .cash, + icon: String = "creditcard", + isDefault: Bool = false, + currency: AppSettings.Currency = .usd, isActive: Bool = true, - creditLimit: Double? = nil, - interestRate: Double? = nil, - dueDate: Date? = nil, - minimumPayment: Double? = nil, - depositEndDate: Date? = nil, - depositInterestRate: Double? = nil, - isAutoRenewable: Bool? = nil, - creditorName: String? = nil, - debtInterestRate: Double? = nil, - paymentSchedule: [DebtPayment]? = nil + isArchived: Bool = false ) { self.id = id self.name = name - self.type = type - self.currency = currency self.balance = balance + self.type = type + self.icon = icon + self.isDefault = isDefault + self.currency = currency self.isActive = isActive - self.creditLimit = creditLimit - self.interestRate = interestRate - self.dueDate = dueDate - self.minimumPayment = minimumPayment - self.depositEndDate = depositEndDate - self.depositInterestRate = depositInterestRate - self.isAutoRenewable = isAutoRenewable - self.creditorName = creditorName - self.debtInterestRate = debtInterestRate - self.paymentSchedule = paymentSchedule + self.isArchived = isArchived } - // Вычисляемые свойства - var availableCredit: Double? { - guard type == .creditCard, let limit = creditLimit else { return nil } - return limit - balance + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + balance = try container.decode(Double.self, forKey: .balance) + type = try container.decode(AccountType.self, forKey: .type) + icon = try container.decode(String.self, forKey: .icon) + isDefault = try container.decode(Bool.self, forKey: .isDefault) + currency = try container.decode(AppSettings.Currency.self, forKey: .currency) + isActive = try container.decode(Bool.self, forKey: .isActive) + isArchived = try container.decode(Bool.self, forKey: .isArchived) } - var isOverdue: Bool { - guard let dueDate = dueDate else { return false } - return Date() > dueDate - } - - var formattedBalance: String { - balance.formatAsCurrency() + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(balance, forKey: .balance) + try container.encode(type, forKey: .type) + try container.encode(icon, forKey: .icon) + try container.encode(isDefault, forKey: .isDefault) + try container.encode(currency, forKey: .currency) + try container.encode(isActive, forKey: .isActive) + try container.encode(isArchived, forKey: .isArchived) } } - -// Sample Data -extension AccountModel { - static let sampleData = [ - AccountModel( - name: "Cash Wallet", - type: .wallet, - currency: .usd, - balance: 1000 - ), - AccountModel( - name: "Main Bank Account", - type: .bankAccount, - currency: .usd, - balance: 5000 - ), - AccountModel( - name: "Credit Card", - type: .creditCard, - currency: .usd, - balance: 2000, - creditLimit: 10000, - interestRate: 19.99, - dueDate: Calendar.current.date(byAdding: .day, value: 15, to: Date()) - ) - ] -} diff --git a/Coinly/Features/Models/AccountType.swift b/Coinly/Features/Models/AccountType.swift index e888cdd..2721ec4 100644 --- a/Coinly/Features/Models/AccountType.swift +++ b/Coinly/Features/Models/AccountType.swift @@ -1,38 +1,38 @@ -// -// AccountType.swift -// Coinly -// -// Created by Vadym Samoilenko on 02/03/2025. -// +import SwiftUI // Изменили import Foundation на SwiftUI для доступа к Color - -import Foundation -import SwiftUI - -enum AccountType: String, Codable, CaseIterable { +enum AccountType: String, Codable, CaseIterable, Equatable { case wallet = "Wallet" + case cash = "Cash" case bankAccount = "Bank Account" case creditCard = "Credit Card" + case savings = "Savings" + case investment = "Investment" case deposit = "Deposit" case debt = "Debt" var icon: String { switch self { - case .wallet: return "banknote" - case .bankAccount: return "building.columns.fill" - case .creditCard: return "creditcard.fill" - case .deposit: return "vault.fill" - case .debt: return "exclamationmark.circle.fill" + case .wallet: return "wallet.pass" + case .cash: return "banknote" + case .bankAccount: return "building.columns" + case .creditCard: return "creditcard" + case .savings: return "piggybank" + case .investment: return "chart.line.uptrend.xyaxis" + case .deposit: return "arrow.down.circle" + case .debt: return "arrow.up.circle" } } var color: Color { switch self { - case .wallet: return Color(uiColor: .systemGreen) - case .bankAccount: return Color(uiColor: .systemBlue) - case .creditCard: return Color(uiColor: .systemPurple) - case .deposit: return Color(uiColor: .systemOrange) - case .debt: return Color(uiColor: .systemRed) + case .wallet: return .blue + case .cash: return .green + case .bankAccount: return .purple + case .creditCard: return .orange + case .savings: return .yellow + case .investment: return .red + case .deposit: return .mint + case .debt: return .pink } } -} \ No newline at end of file +} diff --git a/Coinly/Features/Models/AccountsStore.swift b/Coinly/Features/Models/AccountsStore.swift index 2cbfd42..59a930f 100644 --- a/Coinly/Features/Models/AccountsStore.swift +++ b/Coinly/Features/Models/AccountsStore.swift @@ -1,36 +1,62 @@ import Foundation class AccountsStore: ObservableObject { + static let shared = AccountsStore() + @Published private(set) var accounts: [AccountModel] = [] - init() { - // Загружаем тестовые данные - accounts = AccountModel.sampleData + internal init() { + loadAccounts() } + // MARK: - CRUD Operations + func addAccount(_ account: AccountModel) { accounts.append(account) + saveAccounts() + } + + func deleteAccount(_ account: AccountModel) { + accounts.removeAll { $0.id == account.id } + saveAccounts() } func updateAccount(_ account: AccountModel) { if let index = accounts.firstIndex(where: { $0.id == account.id }) { accounts[index] = account + saveAccounts() } } - func deleteAccount(_ account: AccountModel) { - accounts.removeAll { $0.id == account.id } + // MARK: - Helper Methods + + func totalBalance() -> Double { + accounts + .filter { !$0.isArchived } + .reduce(into: 0) { result, account in + result += account.balanceInDefaultCurrency + } } - func deleteAccount(at indexSet: IndexSet) { - accounts.remove(atOffsets: indexSet) - } - - func getAccount(by id: String) -> AccountModel? { + func getAccount(withId id: String) -> AccountModel? { accounts.first { $0.id == id } } - func getAccounts(of type: AccountType) -> [AccountModel] { - accounts.filter { $0.type == type } + // MARK: - Storage + + private func loadAccounts() { + // TODO: Implement actual persistence + let defaultAccount = AccountModel( + name: "Cash", + balance: 0, + type: .cash, + icon: "banknote", + isDefault: true + ) + accounts = [defaultAccount] + } + + private func saveAccounts() { + // TODO: Implement actual persistence } } diff --git a/Coinly/Features/Models/AppSettings.swift b/Coinly/Features/Models/AppSettings.swift index 8d8cf8e..6bc5f04 100644 --- a/Coinly/Features/Models/AppSettings.swift +++ b/Coinly/Features/Models/AppSettings.swift @@ -1,21 +1,13 @@ import SwiftUI class AppSettings: ObservableObject { - @Published var currency: Currency = .usd { - didSet { - if oldValue != currency { - NotificationCenter.default.post(name: .currencyDidChange, object: nil) - } - } - } - @AppStorage("isDarkMode") var isDarkMode: Bool = false { - didSet { - applyTheme() - } - } - @Published var notificationsEnabled: Bool = true - @Published private(set) var exchangeRates: [String: Double] = [:] - @Published private(set) var lastRatesUpdate: Date? + static let shared = AppSettings() + + @Published var selectedCurrency: Currency = .usd + @Published var monthlyBudget: Double = 0 + @Published var colorScheme: ColorScheme? = nil // Используем ColorScheme из SwiftUI + + private init() {} enum Currency: String, Codable, CaseIterable { case usd = "USD" @@ -30,91 +22,4 @@ class AppSettings: ObservableObject { } } } - - static let shared = AppSettings() - - private init() { - exchangeRates = [ - "USD": 1.0, - "EUR": 0.92, - "GBP": 0.79 - ] - fetchExchangeRates() - applyTheme() - } - - private func applyTheme() { - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { - windowScene.windows.forEach { window in - window.overrideUserInterfaceStyle = isDarkMode ? .dark : .light - } - } - } - - func convert(_ amount: Double, from sourceCurrency: Currency, to targetCurrency: Currency) -> Double { - guard let sourceRate = exchangeRates[sourceCurrency.rawValue], - let targetRate = exchangeRates[targetCurrency.rawValue], - sourceRate > 0, - targetRate > 0 else { - return amount - } - - if sourceCurrency == .usd { - return amount * targetRate - } else if targetCurrency == .usd { - return amount / sourceRate - } else { - let amountInUSD = amount / sourceRate - return amountInUSD * targetRate - } - } - - func fetchExchangeRates() { - let urlString = "https://api.frankfurter.app/latest?from=USD&to=EUR,GBP" - guard let url = URL(string: urlString) else { return } - - URLSession.shared.dataTask(with: url) { [weak self] data, response, error in - guard let data = data else { - print("Error fetching rates: \(error?.localizedDescription ?? "Unknown error")") - return - } - - do { - let response = try JSONDecoder().decode(ExchangeRatesResponse.self, from: data) - DispatchQueue.main.async { - var rates = response.rates - rates["USD"] = 1.0 - - self?.exchangeRates = rates - self?.lastRatesUpdate = Date() - NotificationCenter.default.post(name: .currencyDidChange, object: nil) - - print("Exchange rates updated: \(rates)") - } - } catch { - print("Error decoding rates: \(error.localizedDescription)") - } - }.resume() - } - - var lastUpdateString: String { - guard let date = lastRatesUpdate else { - return "Not updated yet" - } - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - return "Last updated: \(formatter.string(from: date))" - } -} - -struct ExchangeRatesResponse: Codable { - let amount: Double - let base: String - let date: String - let rates: [String: Double] -} - -extension Notification.Name { - static let currencyDidChange = Notification.Name("currencyDidChange") } diff --git a/Coinly/Features/Models/BudgetModel.swift b/Coinly/Features/Models/BudgetModel.swift deleted file mode 100644 index b80dca9..0000000 --- a/Coinly/Features/Models/BudgetModel.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -struct BudgetModel: Identifiable, Codable { - let id: String - var categoryId: String - var amount: Double - var period: BudgetPeriod - var startDate: Date - var isRecurring: Bool - - enum BudgetPeriod: String, Codable, CaseIterable { - case weekly = "Weekly" - case monthly = "Monthly" - case yearly = "Yearly" - - var durationInDays: Int { - switch self { - case .weekly: return 7 - case .monthly: return 30 - case .yearly: return 365 - } - } - } - - init( - id: String = UUID().uuidString, - categoryId: String, - amount: Double, - period: BudgetPeriod = .monthly, - startDate: Date = Date(), - isRecurring: Bool = true - ) { - self.id = id - self.categoryId = categoryId - self.amount = amount - self.period = period - self.startDate = startDate - self.isRecurring = isRecurring - } -} \ No newline at end of file diff --git a/Coinly/Features/Models/CategoryModel.swift b/Coinly/Features/Models/CategoryModel.swift index 46056b5..03c03f9 100644 --- a/Coinly/Features/Models/CategoryModel.swift +++ b/Coinly/Features/Models/CategoryModel.swift @@ -1,41 +1,69 @@ -// -// CategoryModel.swift -// Coinly -// -// Created by Vadym Samoilenko on 02/03/2025. -// - - import SwiftUI -struct CategoryModel: Identifiable { - let id = UUID() - let name: String - let icon: String - let colorName: String // переименовали свойство с color на colorName +struct CategoryModel: Identifiable, Codable { + let id: String + var name: String + var icon: String + var color: String + var type: TransactionType + var isDefault: Bool - static let categories = [ - CategoryModel(name: "Food", icon: "cart.fill", colorName: "systemRed"), - CategoryModel(name: "Transport", icon: "car.fill", colorName: "systemBlue"), - CategoryModel(name: "Entertainment", icon: "tv.fill", colorName: "systemPurple"), - CategoryModel(name: "Shopping", icon: "bag.fill", colorName: "systemOrange"), - CategoryModel(name: "Salary", icon: "dollarsign.circle.fill", colorName: "systemGreen"), - CategoryModel(name: "Other", icon: "square.fill", colorName: "systemGray") + init( + id: String = UUID().uuidString, + name: String, + icon: String, + color: String, + type: TransactionType, + isDefault: Bool = false + ) { + self.id = id + self.name = name + self.icon = icon + self.color = color + self.type = type + self.isDefault = isDefault + } + + func iconColor() -> Color { + Color(color) + } + + static let sampleData = CategoryModel( + name: "Food", + icon: "cart.fill", + color: "systemRed", + type: .expense, + isDefault: true + ) + + static let sampleCategories: [CategoryModel] = [ + CategoryModel( + name: "Food", + icon: "cart.fill", + color: "systemRed", + type: .expense, + isDefault: true + ), + CategoryModel( + name: "Transport", + icon: "car.fill", + color: "systemBlue", + type: .expense, + isDefault: true + ), + CategoryModel( + name: "Shopping", + icon: "bag.fill", + color: "systemGreen", + type: .expense, + isDefault: true + ), + CategoryModel( + name: "Salary", + icon: "dollarsign.circle.fill", + color: "systemPurple", + type: .income, + isDefault: true + ) ] - - static func category(for name: String) -> CategoryModel { - categories.first { $0.name == name } ?? categories.last! - } - - var color: Color { - switch self.colorName { - case "systemRed": return Color(uiColor: .systemRed) - case "systemBlue": return Color(uiColor: .systemBlue) - case "systemPurple": return Color(uiColor: .systemPurple) - case "systemOrange": return Color(uiColor: .systemOrange) - case "systemGreen": return Color(uiColor: .systemGreen) - case "systemGray": return Color(uiColor: .systemGray) - default: return Color(uiColor: .systemGray) - } - } } diff --git a/Coinly/Features/Models/CategoryStore.swift b/Coinly/Features/Models/CategoryStore.swift index b58b60b..8536f7c 100644 --- a/Coinly/Features/Models/CategoryStore.swift +++ b/Coinly/Features/Models/CategoryStore.swift @@ -1,60 +1,84 @@ import Foundation -import Combine class CategoryStore: ObservableObject { + static let shared = CategoryStore() + @Published private(set) var categories: [CategoryModel] = [] @Published private(set) var budgets: [BudgetModel] = [] - init() { - categories = CategoryModel.defaultCategories + private static let defaultCategories: [CategoryModel] = [ + CategoryModel(name: "Salary", icon: "dollarsign.circle.fill", color: "systemGreen", type: .income, isDefault: true), + CategoryModel(name: "Food", icon: "cart.fill", color: "systemRed", type: .expense, isDefault: true), + CategoryModel(name: "Transport", icon: "car.fill", color: "systemBlue", type: .expense, isDefault: true), + CategoryModel(name: "Shopping", icon: "cart.fill", color: "systemOrange", type: .expense, isDefault: true), + CategoryModel(name: "Entertainment", icon: "film.fill", color: "systemPurple", type: .expense, isDefault: true) + ] + + private init() { + loadCategories() + loadBudgets() } - // MARK: - Categories - - func addCategory(_ category: CategoryModel) { - categories.append(category) - } - - func updateCategory(_ category: CategoryModel) { - guard let index = categories.firstIndex(where: { $0.id == category.id }) else { return } - categories[index] = category - } - - func deleteCategory(_ category: CategoryModel) { - guard !category.isDefault else { return } - categories.removeAll { $0.id == category.id } - } + // MARK: - Category Operations func getCategories(of type: TransactionType) -> [CategoryModel] { categories.filter { $0.type == type } } - // MARK: - Budgets - - func addBudget(_ budget: BudgetModel) { - budgets.append(budget) + func getCategory(withId id: String) -> CategoryModel? { + categories.first { $0.id == id } } - func updateBudget(_ budget: BudgetModel) { - guard let index = budgets.firstIndex(where: { $0.id == budget.id }) else { return } - budgets[index] = budget + func addCategory(_ category: CategoryModel) { + categories.append(category) + saveCategories() } - func deleteBudget(_ budget: BudgetModel) { - budgets.removeAll { $0.id == budget.id } + func deleteCategory(_ category: CategoryModel) { + guard !category.isDefault else { return } + categories.removeAll { $0.id == category.id } + saveCategories() + } + + func updateCategory(_ category: CategoryModel) { + if let index = categories.firstIndex(where: { $0.id == category.id }) { + categories[index] = category + saveCategories() + } + } + + // MARK: - Budget Operations + + func setBudget(_ amount: Double, for categoryId: String) { + if let index = budgets.firstIndex(where: { $0.categoryId == categoryId }) { + budgets[index].amount = amount + } else { + budgets.append(BudgetModel(categoryId: categoryId, amount: amount)) + } + saveBudgets() } func getBudget(for categoryId: String) -> BudgetModel? { budgets.first { $0.categoryId == categoryId } } - func getSpentPercentage(for categoryId: String, transactions: [TransactionModel]) -> Double { - guard let budget = getBudget(for: categoryId) else { return 0 } - - let spent = transactions - .filter { $0.categoryId == categoryId } - .reduce(0) { $0 + $1.amount } - - return spent / budget.amount + // MARK: - Storage + + private func loadCategories() { + // TODO: Implement actual persistence + categories = Self.defaultCategories } -} \ No newline at end of file + + private func saveCategories() { + // TODO: Implement actual persistence + } + + private func loadBudgets() { + // TODO: Implement actual persistence + budgets = [] + } + + private func saveBudgets() { + // TODO: Implement actual persistence + } +} diff --git a/Coinly/Features/Models/TransactionFilter.swift b/Coinly/Features/Models/TransactionFilter.swift index 9fe5146..88d574f 100644 --- a/Coinly/Features/Models/TransactionFilter.swift +++ b/Coinly/Features/Models/TransactionFilter.swift @@ -1,49 +1,58 @@ import Foundation struct TransactionFilter { - enum Period { - case all - case today - case week - case month + var startDate: Date? + var endDate: Date? + var type: TransactionType? + var categoryId: String? + var accountId: String? // Добавляем accountId + var period: Period? + + // Добавляем инициализатор + init( + startDate: Date? = nil, + endDate: Date? = nil, + type: TransactionType? = nil, + categoryId: String? = nil, + accountId: String? = nil, + period: Period? = nil + ) { + self.startDate = startDate + self.endDate = endDate + self.type = type + self.categoryId = categoryId + self.accountId = accountId + self.period = period + } + + enum Period: String, CaseIterable, Hashable { + case day = "Day" + case week = "Week" + case month = "Month" + case year = "Year" - func filter(_ date: Date) -> Bool { + var dateInterval: (start: Date, end: Date) { let calendar = Calendar.current let now = Date() switch self { - case .all: - return true - case .today: - return calendar.isDate(date, inSameDayAs: now) + case .day: + let start = calendar.startOfDay(for: now) + let end = calendar.date(byAdding: .day, value: 1, to: start)! + return (start, end) case .week: - let weekAgo = calendar.date(byAdding: .day, value: -7, to: now)! - return date >= weekAgo + let start = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now))! + let end = calendar.date(byAdding: .weekOfYear, value: 1, to: start)! + return (start, end) case .month: - let monthAgo = calendar.date(byAdding: .month, value: -1, to: now)! - return date >= monthAgo + let start = calendar.date(from: calendar.dateComponents([.year, .month], from: now))! + let end = calendar.date(byAdding: .month, value: 1, to: start)! + return (start, end) + case .year: + let start = calendar.date(from: calendar.dateComponents([.year], from: now))! + let end = calendar.date(byAdding: .year, value: 1, to: start)! + return (start, end) } } } - - var period: Period = .all - var selectedCategories: Set = [] - var transactionType: TransactionType? = nil - - func applies(to transaction: TransactionModel) -> Bool { - // Check period - guard period.filter(transaction.date) else { return false } - - // Check categories - if !selectedCategories.isEmpty && !selectedCategories.contains(transaction.category) { - return false - } - - // Check type - if let type = transactionType, transaction.type != type { - return false - } - - return true - } } diff --git a/Coinly/Features/Models/TransactionModel.swift b/Coinly/Features/Models/TransactionModel.swift index fa0604d..d117dbb 100644 --- a/Coinly/Features/Models/TransactionModel.swift +++ b/Coinly/Features/Models/TransactionModel.swift @@ -3,67 +3,73 @@ import Foundation struct TransactionModel: Identifiable, Codable { let id: String var amount: Double - var date: Date var type: TransactionType var category: String + var categoryId: String var note: String? + var date: Date + var accountId: String var originalCurrency: AppSettings.Currency - var isExpense: Bool { - type == .expense + var isExpense: Bool { type == .expense } + + enum CodingKeys: String, CodingKey { + case id + case amount + case type + case category + case categoryId + case note + case date + case accountId + case originalCurrency } - 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 + init( + id: String = UUID().uuidString, + amount: Double, + type: TransactionType, + category: String, + categoryId: String, + note: String? = nil, + date: Date = Date(), + accountId: String, + originalCurrency: AppSettings.Currency = AppSettings.shared.selectedCurrency + ) { + self.id = id self.amount = amount - self.date = date self.type = type self.category = category + self.categoryId = categoryId self.note = note + self.date = date + self.accountId = accountId self.originalCurrency = originalCurrency } - func amountInCurrentCurrency() -> Double { - let settings = AppSettings.shared - return settings.convert(amount, from: originalCurrency, to: settings.currency) + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + amount = try container.decode(Double.self, forKey: .amount) + type = try container.decode(TransactionType.self, forKey: .type) + category = try container.decode(String.self, forKey: .category) + categoryId = try container.decode(String.self, forKey: .categoryId) + note = try container.decodeIfPresent(String.self, forKey: .note) + date = try container.decode(Date.self, forKey: .date) + accountId = try container.decode(String.self, forKey: .accountId) + originalCurrency = try container.decode(AppSettings.Currency.self, forKey: .originalCurrency) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(amount, forKey: .amount) + try container.encode(type, forKey: .type) + try container.encode(category, forKey: .category) + try container.encode(categoryId, forKey: .categoryId) + try container.encodeIfPresent(note, forKey: .note) + try container.encode(date, forKey: .date) + try container.encode(accountId, forKey: .accountId) + try container.encode(originalCurrency, forKey: .originalCurrency) } } - -// Sample Data -extension TransactionModel { - static let sampleData = [ - TransactionModel( - amount: 100, - date: Date(), - type: .income, - category: "Salary", - note: "Monthly salary", - originalCurrency: .usd - ), - TransactionModel( - amount: 25.99, - date: Date(), - type: .expense, - category: "Food", - note: "Lunch", - originalCurrency: .usd - ), - TransactionModel( - amount: 50, - date: Date(), - type: .expense, - category: "Transport", - note: "Fuel", - originalCurrency: .usd - ) - ] -} diff --git a/Coinly/Features/Models/TransactionType.swift b/Coinly/Features/Models/TransactionType.swift index ee76a35..9f97ad9 100644 --- a/Coinly/Features/Models/TransactionType.swift +++ b/Coinly/Features/Models/TransactionType.swift @@ -1,6 +1,10 @@ import Foundation -enum TransactionType: String, Codable { - case income = "Income" +enum TransactionType: String, Codable, CaseIterable, Hashable { case expense = "Expense" + case income = "Income" + + var description: String { + self.rawValue + } } diff --git a/Coinly/Features/Models/TransactionsStore.swift b/Coinly/Features/Models/TransactionsStore.swift deleted file mode 100644 index 2d6da7e..0000000 --- a/Coinly/Features/Models/TransactionsStore.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation - -class TransactionsStore: ObservableObject { - @Published private(set) var transactions: [TransactionModel] = TransactionModel.sampleData - - // 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) - } - - // Фильтрация - 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 - } -} diff --git a/Coinly/Features/Profile/ProfileView.swift b/Coinly/Features/Profile/ProfileView.swift index 41092ce..813b59a 100644 --- a/Coinly/Features/Profile/ProfileView.swift +++ b/Coinly/Features/Profile/ProfileView.swift @@ -1,102 +1,41 @@ import SwiftUI struct ProfileView: View { - @StateObject private var settings = AppSettings.shared - @Environment(\.colorScheme) var colorScheme + @EnvironmentObject private var settings: AppSettings var body: some View { - NavigationView { - List { - Section("Currency Settings") { - Picker("Currency", selection: $settings.currency) { - ForEach(AppSettings.Currency.allCases, id: \.self) { currency in - Text("\(currency.symbol) \(currency.rawValue)") - .tag(currency) - } - } - - Text(settings.lastUpdateString) - .font(.caption) - .foregroundColor(.gray) - - Button("Update Exchange Rates") { - settings.fetchExchangeRates() - } + List { + Section("Appearance") { + Picker("Color Scheme", selection: $settings.colorScheme.animation()) { + Text("System").tag(nil as ColorScheme?) + Text("Light").tag(ColorScheme.light as ColorScheme?) + Text("Dark").tag(ColorScheme.dark as ColorScheme?) } - - Section("Preferences") { - // Dark Mode Toggle - Toggle(isOn: $settings.isDarkMode) { - HStack { - Image(systemName: settings.isDarkMode ? "moon.fill" : "sun.max.fill") - .foregroundColor(settings.isDarkMode ? .purple : .orange) - Text("Dark Mode") - } - } - - // Notifications Toggle - Toggle(isOn: $settings.notificationsEnabled) { - HStack { - Image(systemName: "bell.fill") - .foregroundColor(.blue) - Text("Notifications") - } - } - } - - Section("Categories") { - ForEach(CategoryModel.categories) { category in - HStack { - Image(systemName: category.icon) - .foregroundColor(Color(category.color)) - Text(category.name) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.gray) - } - } - } - - Section("About") { - HStack { - Image(systemName: "info.circle.fill") - .foregroundColor(.blue) - Text("Version") - Spacer() - Text("1.0.0") - .foregroundColor(.gray) - } - - Link(destination: URL(string: "https://www.example.com/privacy")!) { - HStack { - Image(systemName: "lock.fill") - .foregroundColor(.blue) - Text("Privacy Policy") - Spacer() - Image(systemName: "arrow.up.right.square") - .foregroundColor(.blue) - } - } - - Link(destination: URL(string: "https://www.example.com/terms")!) { - HStack { - Image(systemName: "doc.text.fill") - .foregroundColor(.blue) - Text("Terms of Service") - Spacer() - Image(systemName: "arrow.up.right.square") - .foregroundColor(.blue) - } + } + + Section("Currency") { + Picker("Currency", selection: $settings.selectedCurrency) { + ForEach(AppSettings.Currency.allCases, id: \.self) { currency in + Text(currency.rawValue).tag(currency) } } } - .navigationTitle("Profile") + + Section("Budget") { + NavigationLink { + BudgetSettingsView() + } label: { + Text("Budget Settings") + } + } } + .navigationTitle("Profile") } } -struct ProfileView_Previews: PreviewProvider { - static var previews: some View { +#Preview { + NavigationView { ProfileView() + .environmentObject(AppSettings.shared) } } diff --git a/Coinly/Features/Transactions/AddTransactionView.swift b/Coinly/Features/Transactions/AddTransactionView.swift index c079395..9a2538f 100644 --- a/Coinly/Features/Transactions/AddTransactionView.swift +++ b/Coinly/Features/Transactions/AddTransactionView.swift @@ -1,229 +1,133 @@ import SwiftUI struct AddTransactionView: View { - @Environment(\.dismiss) private var dismiss @EnvironmentObject private var settings: AppSettings + @EnvironmentObject private var accountsStore: AccountsStore + @EnvironmentObject private var categoryStore: CategoryStore + @Environment(\.dismiss) private var dismiss - @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 onSave: (TransactionModel) -> Void - let addTransaction: (TransactionModel) -> Void - let editingTransaction: TransactionModel? - let onDelete: (() -> Void)? + @State private var amount: Double = 0 + @State private var type: TransactionType = .expense + @State private var category: CategoryModel? + @State private var note: String = "" + @State private var date = Date() + @State private var account: AccountModel? + @State private var showingCategoryPicker = false + @State private var showingAccountPicker = false - // Инициализатор для создания новой транзакции - 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() + private var isFormValid: Bool { + amount > 0 && category != nil && account != nil } var body: some View { - NavigationView { - ZStack { - Color(uiColor: .systemGroupedBackground) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 24) { - // Amount Input - VStack(spacing: 8) { - HStack { - Spacer() - TextField("0", text: $amount) - .font(.system(size: 48, weight: .medium, design: .rounded)) - .foregroundColor(type == .expense ? .red : .green) - .multilineTextAlignment(.center) - .keyboardType(.decimalPad) - Spacer() - } - - Text(settings.currency.rawValue) - .font(.system(size: 17, weight: .semibold)) - .foregroundColor(Color(uiColor: .secondaryLabel)) - } - .padding(.vertical) - - // Transaction Type - VStack { - Picker("Type", selection: $type) { - Text("Expense").tag(TransactionType.expense) - Text("Income").tag(TransactionType.income) - } - .pickerStyle(.segmented) - } - .padding(.horizontal) - - // Details - VStack(spacing: 0) { - Button { - showingCategories = true - } label: { - HStack { - let selectedCategory = CategoryModel.category(for: category) - Image(systemName: selectedCategory.icon) - .foregroundColor(Color(selectedCategory.color)) - Text("Category") - .foregroundColor(AppStyle.labelPrimary) - Spacer() - Text(category) - .foregroundColor(AppStyle.labelSecondary) - Image(systemName: "chevron.right") - .font(.system(size: 13)) - .foregroundColor(Color(uiColor: .systemGray3)) - } - .padding() - } - - Divider() - .padding(.leading) - - HStack { - Image(systemName: "calendar") - .foregroundColor(.blue) - DatePicker( - "Date", - selection: $date, - displayedComponents: [.date, .hourAndMinute] - ) - .labelsHidden() - } - .padding() - - Divider() - .padding(.leading) - - HStack { - Image(systemName: "text.alignleft") - .foregroundColor(.purple) - TextField("Note", text: $note) - } - .padding() - } - .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) + Form { + Section { + Picker("Type", selection: $type) { + Text("Expense").tag(TransactionType.expense) + Text("Income").tag(TransactionType.income) } + .onChange(of: type) { oldValue, newValue in + category = nil + } + .pickerStyle(.segmented) + + CurrencyField("Amount", value: $amount, currency: settings.selectedCurrency) } - .navigationTitle(navigationTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() + + Section { + Button { + showingCategoryPicker = true + } label: { + HStack { + Text("Category") + Spacer() + if let category = category { + CategoryRowView(category: category) + } else { + Text("Select Category") + .foregroundColor(.secondary) + } } } - ToolbarItem(placement: .confirmationAction) { - Button(saveButtonTitle) { - handleSave() + Button { + showingAccountPicker = true + } label: { + HStack { + Text("Account") + Spacer() + if let account = account { + Text(account.name) + .foregroundColor(.primary) + } else { + Text("Select Account") + .foregroundColor(.secondary) + } } - .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.") + + Section { + DatePicker("Date", selection: $date, displayedComponents: [.date, .hourAndMinute]) + + TextField("Note", text: $note) } } - .sheet(isPresented: $showingCategories) { - CategoryPickerView(selectedCategory: $category) + .navigationTitle("New Transaction") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Add") { + guard let category = category, let account = account else { return } + + let transaction = TransactionModel( + amount: amount, + type: type, + category: category.name, + categoryId: category.id, + note: note.isEmpty ? nil : note, + date: date, + accountId: account.id, + originalCurrency: settings.selectedCurrency + ) + + onSave(transaction) + dismiss() + } + .disabled(!isFormValid) + } + } + .sheet(isPresented: $showingCategoryPicker) { + NavigationView { + CategoryPickerView( + transactionType: type, + onSelect: { category = $0 } + ) + } + } + .sheet(isPresented: $showingAccountPicker) { + NavigationView { + AccountPickerView( + onSelect: { account = $0 } + ) + } } } } -struct AddTransactionView_Previews: PreviewProvider { - static var previews: some View { - Group { - AddTransactionView(addTransaction: { _ in }) - .preferredColorScheme(.light) - - AddTransactionView( - editingTransaction: TransactionModel.sampleData[0], - addTransaction: { _ in }, - onDelete: { } - ) - .preferredColorScheme(.dark) - } - .environmentObject(AppSettings.shared) +#Preview { + NavigationView { + AddTransactionView { _ in } + .environmentObject(AppSettings.shared) + .environmentObject(AccountsStore.shared) + .environmentObject(CategoryStore.shared) } } diff --git a/Coinly/Features/Transactions/TransactionFilterView.swift b/Coinly/Features/Transactions/TransactionFilterView.swift index 014c769..1af453c 100644 --- a/Coinly/Features/Transactions/TransactionFilterView.swift +++ b/Coinly/Features/Transactions/TransactionFilterView.swift @@ -2,119 +2,110 @@ import SwiftUI struct TransactionFilterView: View { @Environment(\.dismiss) private var dismiss - @Binding var filter: TransactionFilter + let filter: TransactionFilter + let onApply: (TransactionFilter) -> Void - private let periods: [(title: String, period: TransactionFilter.Period)] = [ - ("All Time", .all), - ("Today", .today), - ("Last 7 Days", .week), - ("Last 30 Days", .month) - ] + @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) + } var body: some View { - NavigationView { - List { - // Period Section - Section { - 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 - } + List { + Section { + Picker("Period", selection: $period) { + Text("All Time").tag(TransactionFilter.Period?.none) + ForEach(TransactionFilter.Period.allCases, id: \.self) { period in + Text(period.rawValue).tag(Optional(period)) } - } header: { - Text("Time Period") - } - - // Type Section - Section { - 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 = type - } - } - } header: { - Text("Transaction Type") - } - - // Categories Section - Section { - ForEach(CategoryModel.categories) { category in - let isSelected = filter.selectedCategories.contains(category.name) - HStack { - HStack { - Image(systemName: category.icon) - .foregroundColor(Color(category.color)) - 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: { - Text("Categories") - } footer: { - Text("Select categories to filter transactions") } } - .navigationTitle("Filters") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() + + Section { + Picker("Type", selection: $type) { + Text("All Types").tag(TransactionType?.none) + ForEach(TransactionType.allCases, id: \.self) { type in + Text(type.rawValue).tag(Optional(type)) } } - - ToolbarItem(placement: .navigationBarTrailing) { - Button("Reset") { - filter = TransactionFilter() - } + } + + Section { + DatePicker( + "Start Date", + selection: Binding( + get: { startDate ?? Date() }, + set: { startDate = $0 } + ), + displayedComponents: .date + ) + .onChange(of: startDate) { oldValue, newValue in + period = nil } - ToolbarItem(placement: .confirmationAction) { - Button("Done") { - dismiss() - } + DatePicker( + "End Date", + selection: Binding( + get: { endDate ?? Date() }, + set: { endDate = $0 } + ), + displayedComponents: .date + ) + .onChange(of: endDate) { oldValue, newValue in + period = nil + } + } + + Section { + Button("Clear Filter") { + type = nil + period = nil + startDate = nil + endDate = nil + } + .foregroundColor(.red) + } + } + .navigationTitle("Filter") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Apply") { + let newFilter = TransactionFilter( + startDate: startDate, + endDate: endDate, + type: type, + categoryId: filter.categoryId, + accountId: filter.accountId, + period: period + ) + onApply(newFilter) + dismiss() } } } } } -struct TransactionFilterView_Previews: PreviewProvider { - static var previews: some View { - TransactionFilterView(filter: .constant(TransactionFilter())) - .environmentObject(AppSettings.shared) +#Preview { + NavigationView { + TransactionFilterView( + filter: TransactionFilter() + ) { _ in } } } diff --git a/Coinly/Features/Transactions/TransactionsStore.swift b/Coinly/Features/Transactions/TransactionsStore.swift new file mode 100644 index 0000000..22b7c6e --- /dev/null +++ b/Coinly/Features/Transactions/TransactionsStore.swift @@ -0,0 +1,76 @@ +import Foundation + +class TransactionsStore: ObservableObject { + static let shared = TransactionsStore() + + @Published private(set) var transactions: [TransactionModel] = [] + + private init() { + loadTransactions() + } + + // MARK: - CRUD Operations + + func addTransaction(_ transaction: TransactionModel) { + transactions.append(transaction) + saveTransactions() + } + + func deleteTransaction(_ transaction: TransactionModel) { + if let index = transactions.firstIndex(where: { $0.id == transaction.id }) { + transactions.remove(at: index) + saveTransactions() + } + } + + func updateTransaction(_ transaction: TransactionModel) { + if let index = transactions.firstIndex(where: { $0.id == transaction.id }) { + transactions[index] = transaction + saveTransactions() + } + } + + // MARK: - Filtering + + func filterTransactions(filter: TransactionFilter) -> [TransactionModel] { + var filtered = transactions + + if let period = filter.period { + let interval = period.dateInterval + filtered = filtered.filter { $0.date >= interval.start && $0.date < interval.end } + } + + if let startDate = filter.startDate { + filtered = filtered.filter { $0.date >= startDate } + } + + if let endDate = filter.endDate { + filtered = filtered.filter { $0.date <= endDate } + } + + if let type = filter.type { + filtered = filtered.filter { $0.type == type } + } + + if let categoryId = filter.categoryId { + filtered = filtered.filter { $0.categoryId == categoryId } + } + + if let accountId = filter.accountId { + filtered = filtered.filter { $0.accountId == accountId } + } + + return filtered.sorted { $0.date > $1.date } + } + + // MARK: - Storage + + private func loadTransactions() { + // TODO: Implement actual persistence + transactions = [] + } + + private func saveTransactions() { + // TODO: Implement actual persistence + } +} diff --git a/Coinly/Features/Transactions/TransactionsView.swift b/Coinly/Features/Transactions/TransactionsView.swift index 96484df..d680fa4 100644 --- a/Coinly/Features/Transactions/TransactionsView.swift +++ b/Coinly/Features/Transactions/TransactionsView.swift @@ -1,99 +1,54 @@ import SwiftUI struct TransactionsView: View { - @ObservedObject var store: TransactionsStore - @State private var showingAddTransaction = false - @State private var showingFilters = false + @EnvironmentObject private var store: TransactionsStore @State private var searchText = "" - @State private var filter = TransactionFilter() + @State private var showingFilter = false + @State private var showingAddTransaction = false - private var groupedTransactions: [(String, [TransactionModel])] { - let formatter = DateFormatter() - formatter.dateFormat = "MMMM yyyy" - - let filtered = store.filterTransactions(searchText: searchText, filter: filter) - let grouped = Dictionary(grouping: filtered) { transaction in - formatter.string(from: transaction.date) - } - - return grouped.sorted { $0.key > $1.key } + var title: String + var filter: TransactionFilter + + init(title: String = "Transactions", filter: TransactionFilter = TransactionFilter()) { + self.title = title + self.filter = filter + } + + private var filteredTransactions: [TransactionModel] { + store.filterTransactions(filter: filter) } var body: some View { - NavigationView { - ZStack { - Color(uiColor: .systemGroupedBackground) - .ignoresSafeArea() - - if groupedTransactions.isEmpty { - EmptyTransactionsView() - } else { - ScrollView { - LazyVStack(spacing: 24) { - ForEach(groupedTransactions, id: \.0) { month, transactions in - VStack(alignment: .leading, spacing: 12) { - // Month Header - Text(month) - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(Color(uiColor: .secondaryLabel)) - .textCase(.uppercase) - .padding(.horizontal) - - // Transactions - VStack(spacing: 1) { - ForEach(transactions) { transaction in - TransactionRowView(transaction: transaction) - .contextMenu { - Button { - // Edit transaction - } label: { - Label("Edit", systemImage: "pencil") - } - - Button(role: .destructive) { - store.deleteTransaction(transaction) - } label: { - Label("Delete", systemImage: "trash") - } - } - } - } - .background(Color(uiColor: .secondarySystemGroupedBackground)) - .cornerRadius(12) - .padding(.horizontal) - } - } - .padding(.top) + List { + ForEach(filteredTransactions) { transaction in + TransactionRowView(transaction: transaction) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + store.deleteTransaction(transaction) + } label: { + Label("Delete", systemImage: "trash") } } + } + } + .navigationTitle(title) + .searchable(text: $searchText, prompt: "Search transactions") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showingAddTransaction = true + } label: { + Image(systemName: "plus") } } - .navigationTitle("Transactions") - .navigationBarTitleDisplayMode(.large) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { - showingFilters = true - } label: { - Image(systemName: "line.3.horizontal.decrease.circle") - .foregroundColor(.accentColor) - } - } - - ToolbarItem(placement: .navigationBarTrailing) { - Button { - showingAddTransaction = true - } label: { - Image(systemName: "plus") - .foregroundColor(.accentColor) - } + + ToolbarItem(placement: .navigationBarLeading) { + Button { + showingFilter = true + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") } } - .searchable( - text: $searchText, - placement: .navigationBarDrawer(displayMode: .always), - prompt: "Search transactions" - ) } .sheet(isPresented: $showingAddTransaction) { NavigationView { @@ -102,17 +57,21 @@ struct TransactionsView: View { } } } - .sheet(isPresented: $showingFilters) { + .sheet(isPresented: $showingFilter) { NavigationView { - TransactionFilterView(filter: $filter) + TransactionFilterView(filter: filter) { newFilter in + // Handle filter update if needed + } } } } } -struct TransactionsView_Previews: PreviewProvider { - static var previews: some View { - TransactionsView(store: TransactionsStore()) +#Preview { + NavigationView { + TransactionsView() + .environmentObject(TransactionsStore.shared) + .environmentObject(CategoryStore.shared) .environmentObject(AppSettings.shared) } } diff --git a/Coinly/UI/Components/AccountCardView.swift b/Coinly/UI/Components/AccountCardView.swift deleted file mode 100644 index fd45e51..0000000 --- a/Coinly/UI/Components/AccountCardView.swift +++ /dev/null @@ -1,108 +0,0 @@ -import SwiftUI - -struct AccountCardView: View { - let account: AccountModel - @EnvironmentObject private var settings: AppSettings - - private var accountIcon: String { - account.type.icon - } - - private var accountColor: Color { - switch account.type { - case .wallet: return Color(uiColor: .systemBlue) - case .bankAccount: return Color(uiColor: .systemGreen) - case .creditCard: return Color(uiColor: .systemPurple) - case .deposit: return Color(uiColor: .systemOrange) - case .debt: return Color(uiColor: .systemRed) - } - } - - var body: some View { - VStack(spacing: AppStyle.paddingMedium) { - // Header - HStack(alignment: .center) { - // Icon - Image(systemName: accountIcon) - .font(.system(size: 24, weight: .medium)) - .foregroundColor(accountColor) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 4) { - Text(account.name) - .font(AppStyle.fontHeadline) - .foregroundColor(AppStyle.labelPrimary) - - Text(account.type.rawValue) - .font(AppStyle.fontSubheadline) - .foregroundColor(AppStyle.labelSecondary) - } - - Spacer() - - Text(account.currency.symbol) - .font(AppStyle.fontSubheadline) - .foregroundColor(AppStyle.labelSecondary) - } - - Divider() - .padding(.horizontal, -AppStyle.paddingMedium) - - // Balance - VStack(alignment: .leading, spacing: 4) { - Text("Balance") - .font(AppStyle.fontSubheadline) - .foregroundColor(AppStyle.labelSecondary) - - Text(account.balance.formatAsCurrency()) - .font(AppStyle.fontTitle2) - .foregroundColor(AppStyle.labelPrimary) - } - .frame(maxWidth: .infinity, alignment: .leading) - - // Additional Info - if let availableCredit = account.availableCredit { - HStack { - Text("Available Credit") - .font(AppStyle.fontFootnote) - .foregroundColor(AppStyle.labelSecondary) - Spacer() - Text(availableCredit.formatAsCurrency()) - .font(AppStyle.fontCallout) - .foregroundColor(AppStyle.labelPrimary) - } - .padding(.top, 4) - } - - if account.isOverdue { - HStack { - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(Color(uiColor: .systemRed)) - Text("Payment Overdue") - .font(AppStyle.fontFootnote) - .foregroundColor(Color(uiColor: .systemRed)) - } - .padding(.top, 4) - } - } - .padding(AppStyle.paddingMedium) - .cardStyle() - } -} - -// Preview -struct AccountCardView_Previews: PreviewProvider { - static var previews: some View { - Group { - AccountCardView(account: AccountModel.sampleData[0]) - .preferredColorScheme(.light) - - AccountCardView(account: AccountModel.sampleData[2]) - .preferredColorScheme(.dark) - } - .padding() - .background(AppStyle.backgroundPrimary) - .environmentObject(AppSettings.shared) - .previewLayout(.sizeThatFits) - } -} diff --git a/Coinly/UI/Components/AccountRow.swift b/Coinly/UI/Components/AccountRow.swift deleted file mode 100644 index 0f50996..0000000 --- a/Coinly/UI/Components/AccountRow.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// AccountRow.swift -// Coinly -// -// Created by Vadym Samoilenko on 02/03/2025. -// - - -import SwiftUI - -struct AccountRow: View { - let account: AccountModel - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - HStack(spacing: 16) { - Image(systemName: account.type.icon) - .font(.title2) - .foregroundColor(.white) - .frame(width: 40, height: 40) - .background(account.type.color) - .clipShape(RoundedRectangle(cornerRadius: 10)) - - VStack(alignment: .leading, spacing: 4) { - Text(account.name) - .font(AppStyle.fontHeadline) - - Text(account.type.rawValue) - .font(AppStyle.fontSubheadline) - .foregroundColor(AppStyle.labelSecondary) - } - - Spacer() - - VStack(alignment: .trailing, spacing: 4) { - Text(account.formattedBalance) - .font(AppStyle.fontCallout) - - if let available = account.availableCredit { - Text("Available: \(available.formatAsCurrency())") - .font(AppStyle.fontCaption) - .foregroundColor(AppStyle.labelSecondary) - } - } - } - .padding() - .background(AppStyle.backgroundSecondary) - .cornerRadius(AppStyle.cornerRadiusLarge) - } - .buttonStyle(PlainButtonStyle()) - } -} diff --git a/Coinly/UI/Components/AccountsSummaryView.swift b/Coinly/UI/Components/AccountsSummaryView.swift index 7367388..9d75bf1 100644 --- a/Coinly/UI/Components/AccountsSummaryView.swift +++ b/Coinly/UI/Components/AccountsSummaryView.swift @@ -1,58 +1,33 @@ import SwiftUI struct AccountsSummaryView: View { - @ObservedObject var accountsStore: AccountsStore - @EnvironmentObject private var settings: AppSettings - @State private var showingAccountsList = false + @EnvironmentObject private var accountsStore: AccountsStore var body: some View { VStack(spacing: 16) { - // Header with total balance - VStack(spacing: 8) { - Text("Total Assets") - .font(.subheadline) - .foregroundColor(Color(uiColor: .secondaryLabel)) - - Text(accountsStore.accounts.reduce(0) { total, account in - total + (account.type == .creditCard ? 0 : account.balance) - }.formatAsCurrency()) - .font(.title2) - .fontWeight(.bold) - } + Text("Total Balance") + .font(.headline) + .foregroundColor(.secondary) - // Recent accounts preview - VStack(spacing: 12) { - ForEach(Array(accountsStore.accounts.prefix(2))) { account in - AccountRowView(account: account) + Text(AppCurrencyFormatter.format(accountsStore.totalBalance())) + .font(.system(size: 34, weight: .bold)) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(accountsStore.accounts) { account in + AccountCardView(account: account) + .frame(width: 300) + } } - } - - // Show all button - Button { - showingAccountsList = true - } label: { - Text("Show All Accounts") - .font(.headline) - .foregroundColor(.accentColor) - } - } - .padding() - .background(Color(uiColor: .secondarySystemBackground)) - .cornerRadius(12) - .sheet(isPresented: $showingAccountsList) { - NavigationView { - AccountListView(store: accountsStore) + .padding(.horizontal) } } } } -struct AccountsSummaryView_Previews: PreviewProvider { - static var previews: some View { - AccountsSummaryView(accountsStore: AccountsStore()) - .environmentObject(AppSettings.shared) - .padding() - .background(Color(uiColor: .systemBackground)) - .previewLayout(.sizeThatFits) - } +#Preview { + AccountsSummaryView() + .environmentObject(AccountsStore.shared) + .environmentObject(AppSettings.shared) + .padding() } diff --git a/Coinly/UI/Components/CategoryIconView.swift b/Coinly/UI/Components/CategoryIconView.swift new file mode 100644 index 0000000..f21bff0 --- /dev/null +++ b/Coinly/UI/Components/CategoryIconView.swift @@ -0,0 +1,26 @@ +// +// CategoryIconView.swift +// Coinly +// +// Created by Vadym Samoilenko on 03/03/2025. +// + + +import SwiftUI + +struct CategoryIconView: View { + let category: CategoryModel + + var body: some View { + Image(systemName: category.icon) + .font(.title2) + .foregroundColor(.white) + .frame(width: 40, height: 40) + .background(category.iconColor()) + .cornerRadius(8) + } +} + +#Preview { + CategoryIconView(category: CategoryModel.sampleData) +} \ No newline at end of file diff --git a/Coinly/UI/Components/CategoryPickerView.swift b/Coinly/UI/Components/CategoryPickerView.swift index 4d00de6..508bb59 100644 --- a/Coinly/UI/Components/CategoryPickerView.swift +++ b/Coinly/UI/Components/CategoryPickerView.swift @@ -1,58 +1,63 @@ -// -// CategoryPickerView.swift -// Coinly -// -// Created by Vadym Samoilenko on 02/03/2025. -// - - import SwiftUI struct CategoryPickerView: View { + @EnvironmentObject private var categoryStore: CategoryStore @Environment(\.dismiss) private var dismiss - @Binding var selectedCategory: String + @State private var searchText = "" + + let transactionType: TransactionType + let onSelect: (CategoryModel) -> Void + + private var filteredCategories: [CategoryModel] { + let categories = categoryStore.getCategories(of: transactionType) + if searchText.isEmpty { + return categories + } + return categories.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } 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) - } - } - } + List { + ForEach(filteredCategories) { category in + Button { + onSelect(category) + dismiss() + } label: { + CategoryRowView(category: category) } } - .navigationTitle("Select Category") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { + } + .navigationTitle("Select Category") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: "Search categories") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + NavigationLink { + AddCategoryView(type: transactionType) { category in + categoryStore.addCategory(category) + onSelect(category) dismiss() } + } label: { + Image(systemName: "plus") + } + } + + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() } } } } } -struct CategoryPickerView_Previews: PreviewProvider { - static var previews: some View { - CategoryPickerView(selectedCategory: .constant(CategoryModel.categories[0].name)) +#Preview { + NavigationView { + CategoryPickerView( + transactionType: .expense, + onSelect: { _ in } + ) + .environmentObject(CategoryStore.shared) } -} \ No newline at end of file +} diff --git a/Coinly/UI/Components/CategoryRowView.swift b/Coinly/UI/Components/CategoryRowView.swift index 2674739..0a52ce5 100644 --- a/Coinly/UI/Components/CategoryRowView.swift +++ b/Coinly/UI/Components/CategoryRowView.swift @@ -26,4 +26,15 @@ struct CategoryRowView: View { Spacer() } } -} \ No newline at end of file +} + +#Preview { + CategoryRowView(category: CategoryModel( + name: "Food", + icon: "cart.fill", + color: "systemRed", + type: .expense, + isDefault: true + )) + .padding() +} diff --git a/Coinly/UI/Components/ChartView.swift b/Coinly/UI/Components/ChartView.swift new file mode 100644 index 0000000..0ffd569 --- /dev/null +++ b/Coinly/UI/Components/ChartView.swift @@ -0,0 +1,112 @@ +// +// ChartView.swift +// Coinly +// +// Created by Vadym Samoilenko on 02/03/2025. +// + + +import SwiftUI + +struct ChartView: View { + let data: [(String, Double)] + let accentColor: Color + + private var total: Double { + data.reduce(0) { $0 + $1.1 } + } + + var body: some View { + VStack(spacing: 16) { + // Pie Chart + GeometryReader { geometry in + let diameter = min(geometry.size.width, geometry.size.height) + ZStack { + ForEach(data.indices, id: \.self) { index in + PieSlice( + startAngle: startAngle(for: index), + endAngle: endAngle(for: index), + color: accentColor.opacity(opacity(for: index)) + ) + } + } + .frame(width: diameter, height: diameter) + } + .aspectRatio(1, contentMode: .fit) + + // Legend + VStack(spacing: 8) { + ForEach(data, id: \.0) { item in + HStack { + Circle() + .fill(accentColor.opacity(opacity(for: data.firstIndex(where: { $0.0 == item.0 }) ?? 0))) + .frame(width: 12, height: 12) + + Text(item.0) + .font(.subheadline) + + Spacer() + + Text(item.1.formatAsCurrency()) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + } + .padding() + } + + private func startAngle(for index: Int) -> Double { + let prior = data.prefix(index).reduce(0) { $0 + $1.1 } + return (prior / total) * 360 + } + + private func endAngle(for index: Int) -> Double { + let prior = data.prefix(index + 1).reduce(0) { $0 + $1.1 } + return (prior / total) * 360 + } + + private func opacity(for index: Int) -> Double { + let count = Double(data.count) + return 1.0 - (Double(index) * 0.5 / count) + } +} + +struct PieSlice: View { + let startAngle: Double + let endAngle: Double + let color: Color + + var body: some View { + Path { path in + path.move(to: .zero) + path.addArc( + center: .zero, + radius: 1, + startAngle: .degrees(-90 + startAngle), + endAngle: .degrees(-90 + endAngle), + clockwise: false + ) + path.closeSubpath() + } + .fill(color) + .scaleEffect(CGSize(width: 1, height: 1)) + } +} + +struct ChartView_Previews: PreviewProvider { + static var previews: some View { + ChartView( + data: [ + ("Food", 250), + ("Transport", 150), + ("Entertainment", 100), + ("Shopping", 200) + ], + accentColor: .blue + ) + .padding() + .previewLayout(.sizeThatFits) + } +} \ No newline at end of file diff --git a/Coinly/UI/Components/CurrencyField.swift b/Coinly/UI/Components/CurrencyField.swift new file mode 100644 index 0000000..87abed3 --- /dev/null +++ b/Coinly/UI/Components/CurrencyField.swift @@ -0,0 +1,37 @@ +// +// CurrencyField.swift +// Coinly +// +// Created by Vadym Samoilenko on 03/03/2025. +// + + +import SwiftUI + +struct CurrencyField: View { + let title: String + @Binding var value: Double + let currency: AppSettings.Currency + + init(_ title: String, value: Binding, currency: AppSettings.Currency) { + self.title = title + self._value = value + self.currency = currency + } + + var body: some View { + HStack { + Text(title) + Spacer() + TextField("0.00", value: $value, format: .currency(code: currency.rawValue)) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + } + } +} + +#Preview { + Form { + CurrencyField("Amount", value: .constant(100), currency: .usd) + } +} \ No newline at end of file diff --git a/Coinly/UI/Components/PieChartView.swift b/Coinly/UI/Components/PieChartView.swift deleted file mode 100644 index 87f703d..0000000 --- a/Coinly/UI/Components/PieChartView.swift +++ /dev/null @@ -1,81 +0,0 @@ -import SwiftUI - -struct PieChartView: View { - struct PieSlice: Identifiable { - let id = UUID() - let category: CategoryModel - let amount: Double - let percentage: Double - } - - let slices: [PieSlice] - - var body: some View { - GeometryReader { geometry in - ZStack { - ForEach(slices) { slice in - PieSliceShape( - startAngle: startAngle(for: slice), - endAngle: endAngle(for: slice) - ) - .fill(slice.category.color) - } - } - .aspectRatio(1, contentMode: .fit) - } - } - - private func startAngle(for slice: PieSlice) -> Double { - let index = slices.firstIndex(where: { $0.id == slice.id }) ?? 0 - let precedingSlices = slices.prefix(index) - let precedingPercentages = precedingSlices.map { $0.percentage } - let startPercentage = precedingPercentages.reduce(0, +) - return startPercentage * 360 - } - - private func endAngle(for slice: PieSlice) -> Double { - startAngle(for: slice) + (slice.percentage * 360) - } -} - -struct PieSliceShape: Shape { - let startAngle: Double - let endAngle: Double - - func path(in rect: CGRect) -> Path { - let center = CGPoint(x: rect.midX, y: rect.midY) - let radius = min(rect.width, rect.height) / 2 - var path = Path() - - path.move(to: center) - path.addArc( - center: center, - radius: radius, - startAngle: .degrees(startAngle), - endAngle: .degrees(endAngle), - clockwise: false - ) - path.closeSubpath() - - return path - } -} - -struct PieChartView_Previews: PreviewProvider { - static var previews: some View { - PieChartView(slices: [ - PieChartView.PieSlice( - category: CategoryModel.categories[0], - amount: 100, - percentage: 0.4 - ), - PieChartView.PieSlice( - category: CategoryModel.categories[1], - amount: 150, - percentage: 0.6 - ) - ]) - .frame(height: 200) - .padding() - } -} diff --git a/Coinly/UI/Components/TransactionRowView.swift b/Coinly/UI/Components/TransactionRowView.swift index e4ff336..50cd774 100644 --- a/Coinly/UI/Components/TransactionRowView.swift +++ b/Coinly/UI/Components/TransactionRowView.swift @@ -1,90 +1,53 @@ import SwiftUI struct TransactionRowView: View { + @EnvironmentObject private var categoryStore: CategoryStore let transaction: TransactionModel - @EnvironmentObject private var settings: AppSettings - private var category: CategoryModel { - CategoryModel.category(for: transaction.category) + private var category: CategoryModel? { + categoryStore.getCategory(withId: transaction.categoryId) } var body: some View { HStack(spacing: 16) { - // Category Icon - ZStack { - Circle() - .fill(category.color.opacity(0.15)) - .frame(width: 48, height: 48) - - Image(systemName: category.icon) - .font(.system(size: 20)) - .foregroundColor(category.color) + if let category = category { + CategoryIconView(category: category) } - // Transaction Details VStack(alignment: .leading, spacing: 4) { - Text(transaction.category) - .font(.system(size: 17, weight: .semibold)) - .foregroundColor(Color(uiColor: .label)) + Text(category?.name ?? "Unknown Category") + .font(.headline) if let note = transaction.note { Text(note) - .font(.system(size: 15)) - .foregroundColor(Color(uiColor: .secondaryLabel)) + .font(.caption) + .foregroundColor(.secondary) } } Spacer() - // Amount and Date VStack(alignment: .trailing, spacing: 4) { - 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.amount.formatAsCurrency(currency: transaction.originalCurrency)) + .font(.headline) + .foregroundColor(transaction.type == .expense ? .red : .green) - Text(transaction.date, style: .date) - .font(.system(size: 13)) - .foregroundColor(Color(uiColor: .secondaryLabel)) + Text(transaction.date.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundColor(.secondary) } } - .padding(.vertical, 12) - .padding(.horizontal, 16) - .background(Color(uiColor: .secondarySystemGroupedBackground)) } } -struct TransactionRowView_Previews: PreviewProvider { - static var previews: some View { - Group { - TransactionRowView(transaction: TransactionModel( - amount: 42.50, - date: Date(), - type: .expense, - category: "Food", - note: "Lunch at work", - originalCurrency: .usd - )) - .preferredColorScheme(.light) - - TransactionRowView(transaction: TransactionModel( - amount: 1200, - date: Date(), - type: .income, - category: "Salary", - note: "Monthly payment", - originalCurrency: .eur - )) - .preferredColorScheme(.dark) - } - .previewLayout(.sizeThatFits) - .padding() - .environmentObject(AppSettings.shared) - } +#Preview { + TransactionRowView(transaction: TransactionModel( + amount: 100, + type: .expense, + category: "Food", + categoryId: CategoryModel.sampleData.id, + accountId: "1" + )) + .environmentObject(CategoryStore.shared) + .padding() } diff --git a/CoinlyTests/AccountTests.swift b/CoinlyTests/AccountTests.swift index 8191629..cebb757 100644 --- a/CoinlyTests/AccountTests.swift +++ b/CoinlyTests/AccountTests.swift @@ -2,82 +2,23 @@ import XCTest @testable import Coinly final class AccountTests: XCTestCase { - var sut: AccountModel! - - override func setUp() { - super.setUp() - sut = AccountModel( - name: "Test Account", - type: .wallet, - currency: .usd, - balance: 1000 - ) - } - - override func tearDown() { - sut = nil - super.tearDown() - } - func testAccountCreation() { - XCTAssertNotNil(sut) - XCTAssertEqual(sut.name, "Test Account") - XCTAssertEqual(sut.type, .wallet) - XCTAssertEqual(sut.currency, .usd) - XCTAssertEqual(sut.balance, 1000) - XCTAssertTrue(sut.isActive) - } - - func testCreditCardOperations() { - // Создаем кредитную карту для теста - let creditCard = AccountModel( - name: "Test Credit Card", - type: .creditCard, - currency: .usd, - balance: 0, - creditLimit: 1000, - interestRate: 19.99 - ) - - // Тестируем добавление покупки - var card = creditCard - XCTAssertTrue(card.addPurchase(500)) - XCTAssertEqual(card.balance, 500) - - // Тестируем превышение лимита - XCTAssertFalse(card.addPurchase(600)) - XCTAssertEqual(card.balance, 500) - - // Тестируем оплату - XCTAssertTrue(card.makePayment(200)) - XCTAssertEqual(card.balance, 300) - } - - func testAvailableCredit() { - let creditCard = AccountModel( - name: "Test Credit Card", - type: .creditCard, - currency: .usd, - balance: 500, - creditLimit: 1000 - ) - - XCTAssertEqual(creditCard.availableCredit, 500) - } - - func testCurrencyConversion() { let account = AccountModel( - name: "EUR Account", - type: .wallet, - currency: .eur, - balance: 100 + name: "Test Account", + balance: 100, + type: .cash, + icon: "creditcard", + isDefault: true, + currency: .usd, + isActive: true ) - let settings = AppSettings.shared - settings.currency = .usd - - // Используем форматированную строку для сравнения - let expectedAmount = account.balance.formatAsCurrency() - XCTAssertEqual(account.formattedBalance, expectedAmount) + XCTAssertEqual(account.name, "Test Account") + XCTAssertEqual(account.balance, 100) + XCTAssertEqual(account.type, .cash) + XCTAssertEqual(account.currency, .usd) + XCTAssertTrue(account.isActive) } + + // Добавьте другие тесты по необходимости }