diff --git a/Coinly/Features/Accounts/AccountCardView.swift b/Coinly/Features/Accounts/AccountCardView.swift index a1c31ee..555427d 100644 --- a/Coinly/Features/Accounts/AccountCardView.swift +++ b/Coinly/Features/Accounts/AccountCardView.swift @@ -2,41 +2,60 @@ import SwiftUI struct AccountCardView: View { let account: AccountModel + let isSelected: Bool 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) - } + .foregroundColor(account.type.color) + Spacer() - if !account.isActive { - Text("Inactive") - .font(.caption) + + if account.isDefault { + Text("Default") + .font(.caption2) .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(uiColor: .systemGray6)) + .cornerRadius(8) } } - Text(AppCurrencyFormatter.format(account.balance, currency: account.currency)) - .font(.title) - .bold() + VStack(alignment: .leading, spacing: 4) { + Text(account.name) + .font(.headline) + + Text(account.type.rawValue) + .font(.subheadline) + .foregroundColor(.secondary) + } - Text(account.type.rawValue) - .font(.caption) - .foregroundColor(.secondary) + Text(account.balance.formatted(.currency(code: account.currency.rawValue))) + .font(.title2.bold()) } .padding() - .background(account.type.color.opacity(0.1)) - .cornerRadius(12) + .frame(width: 200) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(uiColor: .systemBackground)) + .shadow( + color: isSelected ? Color.accentColor.opacity(0.3) : Color.black.opacity(0.1), + radius: isSelected ? 8 : 4 + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) } } #Preview { - AccountCardView(account: AccountModel.sampleData) - .padding() + AccountCardView( + account: AccountModel.previewData, + isSelected: true + ) } diff --git a/Coinly/Features/Accounts/AccountDetailView.swift b/Coinly/Features/Accounts/AccountDetailView.swift index 88a27e4..ea6e906 100644 --- a/Coinly/Features/Accounts/AccountDetailView.swift +++ b/Coinly/Features/Accounts/AccountDetailView.swift @@ -3,83 +3,86 @@ import SwiftUI struct AccountDetailView: View { @EnvironmentObject private var accountsStore: AccountsStore @EnvironmentObject private var transactionsStore: TransactionsStore - @Environment(\.dismiss) private var dismiss - let account: AccountModel - @State private var showingEditAccount = false + + @State private var showingEditSheet = false + @State private var showingAddTransaction = false + + private var transactions: [TransactionModel] { + transactionsStore.filterTransactions( + filter: TransactionFilter(accountId: account.id) + ) + } + + private func handleAddTransaction(_ transaction: TransactionModel) { + transactionsStore.addTransaction(transaction) + if let targetAccount = accountsStore.getAccount(withId: transaction.accountId ?? "") { + let amount = transaction.type == .income ? transaction.amount : -transaction.amount + let newBalance = targetAccount.balance + amount + accountsStore.updateAccount(targetAccount.with(balance: newBalance)) + } + } var body: some View { List { - Section { - AccountCardView(account: account) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - } + AccountCardView( + account: account, + isSelected: false + ) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) - Section("Recent Transactions") { - let transactions = transactionsStore.filterTransactions( - filter: TransactionFilter(accountId: account.id) + if transactions.isEmpty { + ContentUnavailableView( + "No Transactions", + systemImage: "tray.fill", + description: Text("Add your first transaction to start tracking your finances") ) - - if transactions.isEmpty { - Text("No transactions yet") - .foregroundColor(.secondary) - } else { - ForEach(transactions.prefix(5)) { transaction in - TransactionRowView(transaction: transaction) - } - - NavigationLink { - TransactionsView( - title: "\(account.name) Transactions", - filter: TransactionFilter(accountId: account.id) - ) - } label: { - Text("See All Transactions") - } - } - } - - if !account.isDefault { - Section { - Button(role: .destructive) { - accountsStore.deleteAccount(account) - dismiss() - } label: { - Label("Delete Account", systemImage: "trash") - } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } else { + ForEach(transactions) { transaction in + TransactionRowView(transaction: transaction) } } } .navigationTitle(account.name) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button("Edit") { - showingEditAccount = true + Button { + showingEditSheet = true + } label: { + Text("Edit") + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showingAddTransaction = true + } label: { + Image(systemName: "plus.circle.fill") } } } - .sheet(isPresented: $showingEditAccount) { + .sheet(isPresented: $showingEditSheet) { NavigationView { EditAccountView(account: account) } } + .sheet(isPresented: $showingAddTransaction) { + NavigationView { + AddTransactionView { transaction in + handleAddTransaction(transaction) + } + } + } } } #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) + AccountDetailView(account: AccountModel.previewData) + .environmentObject(AccountsStore.shared) + .environmentObject(TransactionsStore.shared) } } diff --git a/Coinly/Features/Accounts/AccountRowView.swift b/Coinly/Features/Accounts/AccountRowView.swift index a823377..7f91e4f 100644 --- a/Coinly/Features/Accounts/AccountRowView.swift +++ b/Coinly/Features/Accounts/AccountRowView.swift @@ -4,13 +4,10 @@ struct AccountRowView: View { let account: AccountModel var body: some View { - HStack(spacing: 16) { + HStack { Image(systemName: account.icon) - .font(.title2) - .foregroundColor(.white) - .frame(width: 40, height: 40) - .background(account.type.color) - .cornerRadius(8) + .foregroundColor(account.type.color) + .frame(width: 32) VStack(alignment: .leading, spacing: 4) { Text(account.name) @@ -24,7 +21,7 @@ struct AccountRowView: View { Spacer() VStack(alignment: .trailing, spacing: 4) { - Text(AppCurrencyFormatter.format(account.balance, currency: account.currency)) + Text(account.balance.formatted(.currency(code: account.currency.rawValue))) .font(.headline) if account.isDefault { @@ -34,11 +31,11 @@ struct AccountRowView: View { } } } - .opacity(account.isActive ? 1 : 0.5) + .padding(.vertical, 8) } } #Preview { - AccountRowView(account: AccountModel.sampleData) + AccountRowView(account: AccountModel.previewData) .padding() } diff --git a/Coinly/Features/Accounts/AccountsSummaryView.swift b/Coinly/Features/Accounts/AccountsSummaryView.swift new file mode 100644 index 0000000..bb7f490 --- /dev/null +++ b/Coinly/Features/Accounts/AccountsSummaryView.swift @@ -0,0 +1,58 @@ +// Path: Features/Accounts/AccountsSummaryView.swift + +import SwiftUI + +struct AccountsSummaryView: View { + @EnvironmentObject private var accountsStore: AccountsStore + @EnvironmentObject private var settings: AppSettings + + var body: some View { + VStack(spacing: 16) { + Text("Total Balance") + .font(.headline) + .foregroundColor(.secondary) + + Text(accountsStore.totalBalance.formatted(.currency(code: settings.selectedCurrency.rawValue))) + .font(.largeTitle.bold()) + + if accountsStore.accounts.isEmpty { + Text("No accounts") + .font(.callout) + .foregroundColor(.secondary) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(accountsStore.accounts) { account in + VStack(spacing: 8) { + Text(account.name) + .font(.caption) + .foregroundColor(.secondary) + + Text(account.balance.formatted(.currency(code: account.currency.rawValue))) + .font(.callout.bold()) + } + .frame(minWidth: 100) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(Color(uiColor: .secondarySystemBackground)) + .cornerRadius(8) + } + } + .padding(.horizontal) + } + } + } + .padding() + .background(Color(uiColor: .systemBackground)) + .cornerRadius(12) + .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4) + } +} + +#Preview { + AccountsSummaryView() + .environmentObject(AccountsStore.shared) + .environmentObject(AppSettings.shared) + .padding() + .background(Color(uiColor: .systemBackground)) +} diff --git a/Coinly/Features/Accounts/EditAccountView.swift b/Coinly/Features/Accounts/EditAccountView.swift index e6c0b61..023b164 100644 --- a/Coinly/Features/Accounts/EditAccountView.swift +++ b/Coinly/Features/Accounts/EditAccountView.swift @@ -3,7 +3,6 @@ import SwiftUI struct EditAccountView: View { @EnvironmentObject private var accountsStore: AccountsStore @Environment(\.dismiss) private var dismiss - let account: AccountModel @State private var name: String @@ -12,19 +11,15 @@ struct EditAccountView: View { @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) + _name = State(initialValue: account.name) + _type = State(initialValue: account.type) + _balance = State(initialValue: account.balance) + _currency = State(initialValue: account.currency) + _icon = State(initialValue: account.icon) + _isDefault = State(initialValue: account.isDefault) } var body: some View { @@ -48,20 +43,19 @@ struct EditAccountView: View { } Section { - Toggle("Active", isOn: $isActive) Toggle("Set as Default", isOn: $isDefault) - Toggle("Archive", isOn: $isArchived) + } + + Section { + Button("Delete Account", role: .destructive) { + accountsStore.deleteAccount(account) + dismiss() + } } } .navigationTitle("Edit Account") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - dismiss() - } - } - ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { let updatedAccount = AccountModel( @@ -69,11 +63,9 @@ struct EditAccountView: View { name: name, balance: balance, type: type, - icon: icon, + icon: type.icon, isDefault: isDefault, - currency: currency, - isActive: isActive, - isArchived: isArchived + currency: currency ) accountsStore.updateAccount(updatedAccount) dismiss() @@ -86,8 +78,7 @@ struct EditAccountView: View { #Preview { NavigationView { - EditAccountView(account: AccountModel.sampleData) + EditAccountView(account: AccountModel.previewData) .environmentObject(AccountsStore.shared) - .environmentObject(AppSettings.shared) } } diff --git a/Coinly/Features/Dashboard/DashboardView.swift b/Coinly/Features/Dashboard/DashboardView.swift index 985e915..e8232f5 100644 --- a/Coinly/Features/Dashboard/DashboardView.swift +++ b/Coinly/Features/Dashboard/DashboardView.swift @@ -1,53 +1,128 @@ import SwiftUI struct DashboardView: View { - // MARK: - Environment - @EnvironmentObject private var store: TransactionsStore @EnvironmentObject private var accountsStore: AccountsStore - @EnvironmentObject private var categoryStore: CategoryStore + @EnvironmentObject private var transactionsStore: TransactionsStore @EnvironmentObject private var settings: AppSettings - - // MARK: - State + @State private var selectedAccountId: AccountModel.ID? = nil @State private var showingAddTransaction = false - @State private var selectedPeriod: TransactionFilter.Period = .month + @State private var showingFilter = false + @State private var currentFilter = TransactionFilter() + + private var selectedAccount: AccountModel? { + selectedAccountId.flatMap { id in + accountsStore.getAccount(withId: id) + } ?? accountsStore.accounts.first + } - // MARK: - Computed Properties private var filteredTransactions: [TransactionModel] { - store.filterTransactions(filter: TransactionFilter(period: selectedPeriod)) + var filter = currentFilter + filter.accountId = selectedAccountId + return transactionsStore.filterTransactions(filter: filter) } - private var totalIncome: Double { - filteredTransactions - .filter { $0.type == .income } - .reduce(0) { $0 + $1.amount } + private func getFilteredTransactions(type: TransactionType?) -> [TransactionModel] { + let filter = TransactionFilter( + type: type, + accountId: selectedAccountId, + period: .month + ) + return transactionsStore.filterTransactions(filter: filter) } - private var totalExpenses: Double { - filteredTransactions - .filter { $0.type == .expense } - .reduce(0) { $0 + $1.amount } + private var monthlyIncome: Double { + let transactions = getFilteredTransactions(type: .income) + return transactions.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 + private var monthlyExpenses: Double { + let transactions = getFilteredTransactions(type: .expense) + return transactions.reduce(0) { $0 + $1.amount } + } + + private func handleAddTransaction(_ transaction: TransactionModel) { + transactionsStore.addTransaction(transaction) + if let accountId = transaction.accountId, + let account = accountsStore.getAccount(withId: accountId) { + let amount = transaction.type == .income ? transaction.amount : -transaction.amount + let newBalance = account.balance + amount + accountsStore.updateAccount(account.with(balance: newBalance)) } - .sorted { $0.1 > $1.1 } } - // MARK: - Body var body: some View { ScrollView { - VStack(spacing: 20) { - balanceCard - periodSelector - if !expenseCategories.isEmpty { expenseCategoriesSection } - if !filteredTransactions.isEmpty { recentTransactionsSection } + VStack(spacing: 16) { + // Account Cards Carousel + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 16) { + ForEach(accountsStore.accounts) { account in + AccountCardView( + account: account, + isSelected: account.id == selectedAccountId + ) + .onTapGesture { + withAnimation { + selectedAccountId = account.id + } + } + } + } + .padding(.horizontal) + } + .frame(height: 200) + + // Stats + HStack(spacing: 16) { + StatCard( + title: "Income", + amount: monthlyIncome, + color: .green, + currency: settings.selectedCurrency + ) + + StatCard( + title: "Expenses", + amount: monthlyExpenses, + color: .red, + currency: settings.selectedCurrency + ) + } + .padding(.horizontal) + + // Transactions List + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Transactions") + .font(.title2.bold()) + + Spacer() + + Button { + showingFilter = true + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + .foregroundColor(.accentColor) + } + } + .padding(.horizontal) + + if filteredTransactions.isEmpty { + ContentUnavailableView( + "No Transactions", + systemImage: "tray.fill", + description: Text("Add your first transaction to start tracking your finances") + ) + } else { + LazyVStack(spacing: 8) { + ForEach(filteredTransactions) { transaction in + TransactionRowView(transaction: transaction) + .padding(.horizontal) + } + } + } + } } - .padding() } .navigationTitle("Dashboard") .toolbar { @@ -55,144 +130,60 @@ struct DashboardView: View { Button { showingAddTransaction = true } label: { - Image(systemName: "plus") + Image(systemName: "plus.circle.fill") + .foregroundColor(.accentColor) } + .disabled(accountsStore.accounts.isEmpty) } } .sheet(isPresented: $showingAddTransaction) { NavigationView { AddTransactionView { transaction in - store.addTransaction(transaction) + handleAddTransaction(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() + .sheet(isPresented: $showingFilter) { + NavigationView { + TransactionFilterView(filter: currentFilter) { filter in + currentFilter = filter } } } - .padding() - .background(Color(uiColor: .secondarySystemGroupedBackground)) - .cornerRadius(12) - } - - private var recentTransactionsSection: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Recent Transactions") - .font(.headline) - - Spacer() - - 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) } } -// MARK: - Preview +// MARK: - Supporting Views + +private struct StatCard: View { + let title: String + let amount: Double + let color: Color + let currency: AppSettings.Currency + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.subheadline) + .foregroundColor(.secondary) + + Text(amount.formatted(.currency(code: currency.rawValue))) + .font(.headline) + .foregroundColor(color) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(uiColor: .systemBackground)) + .cornerRadius(12) + .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4) + } +} + #Preview { NavigationView { DashboardView() - .environmentObject(TransactionsStore.shared) .environmentObject(AccountsStore.shared) - .environmentObject(CategoryStore.shared) + .environmentObject(TransactionsStore.shared) .environmentObject(AppSettings.shared) } } diff --git a/Coinly/Features/Models/AccountModel.swift b/Coinly/Features/Models/AccountModel.swift index ed0f29d..85be0f6 100644 --- a/Coinly/Features/Models/AccountModel.swift +++ b/Coinly/Features/Models/AccountModel.swift @@ -1,55 +1,22 @@ import Foundation -class AccountModel: ObservableObject, Identifiable, Codable { +struct AccountModel: Identifiable, Hashable { let id: String - @Published var name: String - @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 - - var balanceInDefaultCurrency: Double { - // TODO: Implement currency conversion - // Пока просто возвращаем баланс без конвертации - balance - } - - static let sampleData = AccountModel( - name: "Sample Account", - balance: 1000, - type: .cash, - icon: "creditcard", - isDefault: true, - currency: .usd, - isActive: true, - isArchived: false - ) - - enum CodingKeys: String, CodingKey { - case id - case name - case balance - case type - case icon - case isDefault - case currency - case isActive - case isArchived - } + let name: String + var balance: Double + let type: AccountType + let icon: String + let isDefault: Bool + let currency: AppSettings.Currency init( id: String = UUID().uuidString, name: String, - balance: Double = 0, - type: AccountType = .cash, - icon: String = "creditcard", + balance: Double, + type: AccountType, + icon: String, isDefault: Bool = false, - currency: AppSettings.Currency = .usd, - isActive: Bool = true, - isArchived: Bool = false + currency: AppSettings.Currency ) { self.id = id self.name = name @@ -58,33 +25,58 @@ class AccountModel: ObservableObject, Identifiable, Codable { self.icon = icon self.isDefault = isDefault self.currency = currency - self.isActive = isActive - self.isArchived = isArchived } - 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) + func with(balance: Double) -> AccountModel { + AccountModel( + id: self.id, + name: self.name, + balance: balance, + type: self.type, + icon: self.icon, + isDefault: self.isDefault, + currency: self.currency + ) } - 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) + static func == (lhs: AccountModel, rhs: AccountModel) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) } } + +// MARK: - Preview Data +extension AccountModel { + static let previewData = AccountModel( + id: "preview", + name: "Cash", + balance: 1000, + type: .cash, + icon: "banknote", + isDefault: true, + currency: .usd + ) + + static let previewItems: [AccountModel] = [ + previewData, + AccountModel( + id: "bank", + name: "Bank Account", + balance: 5000, + type: .bankAccount, + icon: "building.columns.fill", + currency: .usd + ), + AccountModel( + id: "credit", + name: "Credit Card", + balance: -500, + type: .creditCard, + icon: "creditcard.fill", + currency: .usd + ) + ] +} diff --git a/Coinly/Features/Models/AccountsStore.swift b/Coinly/Features/Models/AccountsStore.swift index 59a930f..399f10a 100644 --- a/Coinly/Features/Models/AccountsStore.swift +++ b/Coinly/Features/Models/AccountsStore.swift @@ -5,58 +5,57 @@ class AccountsStore: ObservableObject { @Published private(set) var accounts: [AccountModel] = [] - internal init() { - loadAccounts() + var totalBalance: Double { + accounts.reduce(0) { $0 + $1.balance } } - // MARK: - CRUD Operations + private init() { + #if DEBUG + self.accounts = AccountModel.previewItems + #endif + } func addAccount(_ account: AccountModel) { + if account.isDefault { + accounts = accounts.map { acc in + AccountModel( + id: acc.id, + name: acc.name, + balance: acc.balance, + type: acc.type, + icon: acc.icon, + isDefault: false, + currency: acc.currency + ) + } + } 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 }) { + if account.isDefault { + accounts = accounts.map { acc in + AccountModel( + id: acc.id, + name: acc.name, + balance: acc.balance, + type: acc.type, + icon: acc.icon, + isDefault: false, + currency: acc.currency + ) + } + } accounts[index] = account - saveAccounts() } } - // MARK: - Helper Methods - - func totalBalance() -> Double { - accounts - .filter { !$0.isArchived } - .reduce(into: 0) { result, account in - result += account.balanceInDefaultCurrency - } + func deleteAccount(_ account: AccountModel) { + accounts.removeAll { $0.id == account.id } } func getAccount(withId id: String) -> AccountModel? { accounts.first { $0.id == id } } - - // 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/TransactionFilter.swift b/Coinly/Features/Models/TransactionFilter.swift index 88d574f..d714dd0 100644 --- a/Coinly/Features/Models/TransactionFilter.swift +++ b/Coinly/Features/Models/TransactionFilter.swift @@ -1,3 +1,5 @@ +// Features/Models/TransactionFilter.swift + import Foundation struct TransactionFilter { @@ -5,10 +7,9 @@ struct TransactionFilter { var endDate: Date? var type: TransactionType? var categoryId: String? - var accountId: String? // Добавляем accountId + var accountId: String? var period: Period? - // Добавляем инициализатор init( startDate: Date? = nil, endDate: Date? = nil, @@ -23,35 +24,46 @@ struct TransactionFilter { self.categoryId = categoryId self.accountId = accountId self.period = period + + if let period = period { + self.startDate = period.dateInterval.start + self.endDate = period.dateInterval.end + } } - enum Period: String, CaseIterable, Hashable { - case day = "Day" - case week = "Week" - case month = "Month" - case year = "Year" + enum Period: String, CaseIterable { + case day = "Today" + case week = "This Week" + case month = "This Month" + case year = "This Year" - var dateInterval: (start: Date, end: Date) { + var dateInterval: DateInterval { let calendar = Calendar.current let now = Date() switch self { case .day: let start = calendar.startOfDay(for: now) - let end = calendar.date(byAdding: .day, value: 1, to: start)! - return (start, end) + let end = calendar.date(byAdding: .day, value: 1, to: start) ?? now + return DateInterval(start: start, end: end) + case .week: - let start = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now))! - let end = calendar.date(byAdding: .weekOfYear, value: 1, to: start)! - return (start, end) + if let interval = calendar.dateInterval(of: .weekOfMonth, for: now) { + return interval + } + return DateInterval(start: now, end: now) + case .month: - let start = calendar.date(from: calendar.dateComponents([.year, .month], from: now))! - let end = calendar.date(byAdding: .month, value: 1, to: start)! - return (start, end) + if let interval = calendar.dateInterval(of: .month, for: now) { + return interval + } + return DateInterval(start: now, end: now) + 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) + if let interval = calendar.dateInterval(of: .year, for: now) { + return interval + } + return DateInterval(start: now, end: now) } } } diff --git a/Coinly/Features/Models/TransactionModel.swift b/Coinly/Features/Models/TransactionModel.swift index d117dbb..68192fb 100644 --- a/Coinly/Features/Models/TransactionModel.swift +++ b/Coinly/Features/Models/TransactionModel.swift @@ -1,40 +1,26 @@ import Foundation -struct TransactionModel: Identifiable, Codable { +struct TransactionModel: Identifiable { let id: String - var amount: Double - 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 } - - enum CodingKeys: String, CodingKey { - case id - case amount - case type - case category - case categoryId - case note - case date - case accountId - case originalCurrency - } + let amount: Double + let type: TransactionType + let category: String + let categoryId: String? + let note: String? + let date: Date + let accountId: String? + let originalCurrency: AppSettings.Currency init( id: String = UUID().uuidString, amount: Double, type: TransactionType, category: String, - categoryId: String, + categoryId: String?, note: String? = nil, date: Date = Date(), - accountId: String, - originalCurrency: AppSettings.Currency = AppSettings.shared.selectedCurrency + accountId: String?, + originalCurrency: AppSettings.Currency ) { self.id = id self.amount = amount @@ -46,30 +32,38 @@ struct TransactionModel: Identifiable, Codable { self.accountId = accountId self.originalCurrency = originalCurrency } - - 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) - } +} + +// MARK: - Preview Data +extension TransactionModel { + static let previewData = TransactionModel( + amount: 42.50, + type: .expense, + category: "Food", + categoryId: "food", + note: "Lunch", + accountId: AccountModel.previewData.id, + originalCurrency: .usd + ) + + static let previewItems: [TransactionModel] = [ + previewData, + TransactionModel( + amount: 1000, + type: .income, + category: "Salary", + categoryId: "salary", + note: "Monthly salary", + accountId: AccountModel.previewData.id, + originalCurrency: .usd + ), + TransactionModel( + amount: 15.99, + type: .expense, + category: "Entertainment", + categoryId: "entertainment", + accountId: AccountModel.previewData.id, + originalCurrency: .usd + ) + ] } diff --git a/Coinly/Features/Transactions/TransactionRowView.swift b/Coinly/Features/Transactions/TransactionRowView.swift new file mode 100644 index 0000000..d2c4140 --- /dev/null +++ b/Coinly/Features/Transactions/TransactionRowView.swift @@ -0,0 +1,45 @@ +// Features/Transactions/TransactionRowView.swift + +import SwiftUI + +struct TransactionRowView: View { + @EnvironmentObject private var categoryStore: CategoryStore + let transaction: TransactionModel + + private var category: CategoryModel? { + guard let categoryId = transaction.categoryId else { return nil } + return categoryStore.getCategory(withId: categoryId) + } + + var body: some View { + HStack { + if let category = category { + CategoryRowView(category: category) + } else { + Image(systemName: "questionmark.circle.fill") + .foregroundColor(.secondary) + Text(transaction.category) + .foregroundColor(.secondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Text(transaction.amount.formatted(.currency(code: transaction.originalCurrency.rawValue))) + .font(.headline) + .foregroundColor(transaction.type == .income ? .green : .primary) + + Text(transaction.date.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 8) + } +} + +#Preview { + TransactionRowView(transaction: TransactionModel.previewData) + .environmentObject(CategoryStore.shared) + .padding() +} diff --git a/Coinly/Features/Transactions/TransactionsStore.swift b/Coinly/Features/Transactions/TransactionsStore.swift index 22b7c6e..fedc076 100644 --- a/Coinly/Features/Transactions/TransactionsStore.swift +++ b/Coinly/Features/Transactions/TransactionsStore.swift @@ -6,71 +6,55 @@ class TransactionsStore: ObservableObject { @Published private(set) var transactions: [TransactionModel] = [] private init() { - loadTransactions() + #if DEBUG + self.transactions = TransactionModel.previewItems + #endif } - // MARK: - CRUD Operations - func addTransaction(_ transaction: TransactionModel) { transactions.append(transaction) - saveTransactions() + } + + func filterTransactions(filter: TransactionFilter) -> [TransactionModel] { + transactions.filter { transaction in + var matches = true + + if let startDate = filter.startDate { + matches = matches && transaction.date >= startDate + } + + if let endDate = filter.endDate { + matches = matches && transaction.date < endDate + } + + if let type = filter.type { + matches = matches && transaction.type == type + } + + if let categoryId = filter.categoryId { + matches = matches && transaction.categoryId == categoryId + } + + if let accountId = filter.accountId { + matches = matches && transaction.accountId == accountId + } + + return matches + } + .sorted { $0.date > $1.date } } func deleteTransaction(_ transaction: TransactionModel) { - if let index = transactions.firstIndex(where: { $0.id == transaction.id }) { - transactions.remove(at: index) - saveTransactions() - } + transactions.removeAll { $0.id == transaction.id } } 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 + func getTransaction(withId id: String) -> TransactionModel? { + transactions.first { $0.id == id } } } diff --git a/Coinly/UI/Components/AccountsSummaryView.swift b/Coinly/UI/Components/AccountsSummaryView.swift deleted file mode 100644 index 9d75bf1..0000000 --- a/Coinly/UI/Components/AccountsSummaryView.swift +++ /dev/null @@ -1,33 +0,0 @@ -import SwiftUI - -struct AccountsSummaryView: View { - @EnvironmentObject private var accountsStore: AccountsStore - - var body: some View { - VStack(spacing: 16) { - Text("Total Balance") - .font(.headline) - .foregroundColor(.secondary) - - 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) - } - } - .padding(.horizontal) - } - } - } -} - -#Preview { - AccountsSummaryView() - .environmentObject(AccountsStore.shared) - .environmentObject(AppSettings.shared) - .padding() -} diff --git a/Coinly/UI/Components/TransactionRowView.swift b/Coinly/UI/Components/TransactionRowView.swift deleted file mode 100644 index 50cd774..0000000 --- a/Coinly/UI/Components/TransactionRowView.swift +++ /dev/null @@ -1,53 +0,0 @@ -import SwiftUI - -struct TransactionRowView: View { - @EnvironmentObject private var categoryStore: CategoryStore - let transaction: TransactionModel - - private var category: CategoryModel? { - categoryStore.getCategory(withId: transaction.categoryId) - } - - var body: some View { - HStack(spacing: 16) { - if let category = category { - CategoryIconView(category: category) - } - - VStack(alignment: .leading, spacing: 4) { - Text(category?.name ?? "Unknown Category") - .font(.headline) - - if let note = transaction.note { - Text(note) - .font(.caption) - .foregroundColor(.secondary) - } - } - - Spacer() - - VStack(alignment: .trailing, spacing: 4) { - Text(transaction.amount.formatAsCurrency(currency: transaction.originalCurrency)) - .font(.headline) - .foregroundColor(transaction.type == .expense ? .red : .green) - - Text(transaction.date.formatted(date: .abbreviated, time: .shortened)) - .font(.caption) - .foregroundColor(.secondary) - } - } - } -} - -#Preview { - TransactionRowView(transaction: TransactionModel( - amount: 100, - type: .expense, - category: "Food", - categoryId: CategoryModel.sampleData.id, - accountId: "1" - )) - .environmentObject(CategoryStore.shared) - .padding() -}