From 0001b770529c1d0fd0a9d0cc2d660d6a0fced05b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CSamoilenkoVadym=E2=80=9D?= <“samoylenko.vadym@gmail.com”> Date: Sun, 2 Mar 2025 19:28:52 +0000 Subject: [PATCH] Fix currency conversion calculations and total balance computation --- Coinly/App/ContentView.swift | 4 +- Coinly/Core/Utils/CurrencyFormatter.swift | 8 ++ Coinly/Features/Dashboard/DashboardView.swift | 40 ++++--- Coinly/Features/Models/AppSettings.swift | 110 ++++++++++++++++++ .../Features/Models/TransactionFilter.swift | 10 +- Coinly/Features/Models/TransactionModel.swift | 62 +++++++--- Coinly/Features/Models/TransactionType.swift | 6 + .../Features/Models/TransactionsStore.swift | 23 ++-- Coinly/Features/Profile/ProfileView.swift | 98 +++++++++++++--- .../Transactions/AddTransactionView.swift | 19 ++- Coinly/UI/Components/TransactionRowView.swift | 12 +- 11 files changed, 312 insertions(+), 80 deletions(-) create mode 100644 Coinly/Core/Utils/CurrencyFormatter.swift create mode 100644 Coinly/Features/Models/AppSettings.swift create mode 100644 Coinly/Features/Models/TransactionType.swift diff --git a/Coinly/App/ContentView.swift b/Coinly/App/ContentView.swift index f520263..b47435d 100644 --- a/Coinly/App/ContentView.swift +++ b/Coinly/App/ContentView.swift @@ -2,10 +2,11 @@ import SwiftUI struct ContentView: View { @StateObject private var transactionsStore = TransactionsStore() + @StateObject private var settings = AppSettings.shared var body: some View { TabView { - DashboardView(transactions: transactionsStore.transactions) + DashboardView(store: transactionsStore) .tabItem { Label("Dashboard", systemImage: "chart.pie.fill") } @@ -20,5 +21,6 @@ struct ContentView: View { Label("Profile", systemImage: "person.fill") } } + .environmentObject(settings) } } diff --git a/Coinly/Core/Utils/CurrencyFormatter.swift b/Coinly/Core/Utils/CurrencyFormatter.swift new file mode 100644 index 0000000..e42c7ed --- /dev/null +++ b/Coinly/Core/Utils/CurrencyFormatter.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Double { + func formatAsCurrency() -> String { + let settings = AppSettings.shared + return String(format: "%@%.2f", settings.currency.symbol, self) + } +} diff --git a/Coinly/Features/Dashboard/DashboardView.swift b/Coinly/Features/Dashboard/DashboardView.swift index 6bb80c3..63785fa 100644 --- a/Coinly/Features/Dashboard/DashboardView.swift +++ b/Coinly/Features/Dashboard/DashboardView.swift @@ -1,27 +1,34 @@ import SwiftUI struct DashboardView: View { - let transactions: [TransactionModel] + @ObservedObject var store: TransactionsStore + @EnvironmentObject private var settings: AppSettings private var totalBalance: Double { - transactions.reduce(0) { $0 + $1.signedAmount } + store.transactions.reduce(0) { total, transaction in + let amount = transaction.amountInCurrentCurrency() + return total + (transaction.isExpense ? -amount : amount) + } } private var income: Double { - transactions.filter { !$0.isExpense }.reduce(0) { $0 + $1.amount } + store.transactions + .filter { !$0.isExpense } + .reduce(0) { $0 + $1.amountInCurrentCurrency() } } private var expenses: Double { - transactions.filter { $0.isExpense }.reduce(0) { $0 + $1.amount } + store.transactions + .filter { $0.isExpense } + .reduce(0) { $0 + $1.amountInCurrentCurrency() } } private var categoryExpenses: [PieChartView.PieSlice] { - // Группируем расходы по категориям - let expensesByCategory = Dictionary(grouping: transactions.filter { $0.isExpense }) { $0.category } + let expensesByCategory = Dictionary(grouping: store.transactions.filter { $0.isExpense }) { $0.category } let totalExpenses = expenses return expensesByCategory.map { category, transactions in - let categoryTotal = transactions.reduce(0) { $0 + $1.amount } + let categoryTotal = transactions.reduce(0) { $0 + $1.amountInCurrentCurrency() } let percentage = totalExpenses > 0 ? categoryTotal / totalExpenses : 0 return PieChartView.PieSlice( category: CategoryModel.category(for: category), @@ -41,7 +48,7 @@ struct DashboardView: View { .font(.subheadline) .foregroundColor(.gray) - Text(String(format: "$ %.2f", totalBalance)) + Text(totalBalance.formatAsCurrency()) .font(.title) .fontWeight(.bold) } @@ -58,7 +65,7 @@ struct DashboardView: View { Text("Income") .font(.subheadline) .foregroundColor(.gray) - Text(String(format: "$ %.2f", income)) + Text(income.formatAsCurrency()) .font(.headline) .foregroundColor(.green) } @@ -73,7 +80,7 @@ struct DashboardView: View { Text("Expenses") .font(.subheadline) .foregroundColor(.gray) - Text(String(format: "$ %.2f", expenses)) + Text(expenses.formatAsCurrency()) .font(.headline) .foregroundColor(.red) } @@ -107,7 +114,7 @@ struct DashboardView: View { Spacer() - Text(String(format: "$ %.2f", slice.amount)) + Text(slice.amount.formatAsCurrency()) .foregroundColor(.secondary) Text(String(format: "(%.1f%%)", slice.percentage * 100)) @@ -127,9 +134,9 @@ struct DashboardView: View { Text("Recent Transactions") .font(.headline) - ForEach(Array(transactions.prefix(3))) { transaction in + ForEach(Array(store.transactions.prefix(3))) { transaction in TransactionRowView(transaction: transaction) - if transaction.id != transactions.prefix(3).last?.id { + if transaction.id != store.transactions.prefix(3).last?.id { Divider() } } @@ -147,3 +154,10 @@ struct DashboardView: View { } } } + +struct DashboardView_Previews: PreviewProvider { + static var previews: some View { + DashboardView(store: TransactionsStore()) + .environmentObject(AppSettings.shared) + } +} diff --git a/Coinly/Features/Models/AppSettings.swift b/Coinly/Features/Models/AppSettings.swift new file mode 100644 index 0000000..df83304 --- /dev/null +++ b/Coinly/Features/Models/AppSettings.swift @@ -0,0 +1,110 @@ +import Foundation + +class AppSettings: ObservableObject { + @Published var currency: Currency = .usd { + didSet { + if oldValue != currency { + NotificationCenter.default.post(name: .currencyDidChange, object: nil) + } + } + } + @Published var isDarkMode: Bool = false + @Published var notificationsEnabled: Bool = true + @Published private(set) var exchangeRates: [String: Double] = [:] + @Published private(set) var lastRatesUpdate: Date? + + enum Currency: String, Codable, CaseIterable { + case usd = "USD" + case eur = "EUR" + case gbp = "GBP" + + var symbol: String { + switch self { + case .usd: return "$" + case .eur: return "€" + case .gbp: return "£" + } + } + } + + static let shared = AppSettings() + + private init() { + // Инициализируем базовые курсы (1 USD = ...) + exchangeRates = [ + "USD": 1.0, // 1 USD = 1 USD + "EUR": 0.92, // 1 USD = 0.92 EUR + "GBP": 0.79 // 1 USD = 0.79 GBP + ] + fetchExchangeRates() + } + + 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 + } + + // Сначала конвертируем в USD, затем в целевую валюту + if sourceCurrency == .usd { + return amount * targetRate + } else if targetCurrency == .usd { + return amount / sourceRate + } else { + // Конвертация через USD + 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 // Базовая валюта всегда 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/TransactionFilter.swift b/Coinly/Features/Models/TransactionFilter.swift index 11ea04f..9fe5146 100644 --- a/Coinly/Features/Models/TransactionFilter.swift +++ b/Coinly/Features/Models/TransactionFilter.swift @@ -1,11 +1,3 @@ -// -// TransactionFilter.swift -// Coinly -// -// Created by Vadym Samoilenko on 02/03/2025. -// - - import Foundation struct TransactionFilter { @@ -54,4 +46,4 @@ struct TransactionFilter { return true } -} \ No newline at end of file +} diff --git a/Coinly/Features/Models/TransactionModel.swift b/Coinly/Features/Models/TransactionModel.swift index 9e457a7..87f29da 100644 --- a/Coinly/Features/Models/TransactionModel.swift +++ b/Coinly/Features/Models/TransactionModel.swift @@ -1,25 +1,23 @@ -// -// TransactionType.swift -// Coinly -// -// Created by Vadym Samoilenko on 02/03/2025. -// - - import Foundation -enum TransactionType: String { - case income = "Income" - case expense = "Expense" -} - -struct TransactionModel: Identifiable { - let id = UUID() +struct TransactionModel: Identifiable, Codable { + let id: String var amount: Double var date: Date var type: TransactionType var category: String var note: String? + var originalCurrency: AppSettings.Currency + + init(amount: Double, date: Date, type: TransactionType, category: String, note: String?, originalCurrency: AppSettings.Currency) { + self.id = UUID().uuidString + self.amount = amount + self.date = date + self.type = type + self.category = category + self.note = note + self.originalCurrency = originalCurrency + } var isExpense: Bool { type == .expense @@ -28,13 +26,39 @@ struct TransactionModel: Identifiable { var signedAmount: Double { isExpense ? -amount : amount } + + func amountInCurrentCurrency() -> Double { + let settings = AppSettings.shared + return settings.convert(amount, from: originalCurrency, to: settings.currency) + } } // Sample Data extension TransactionModel { static let sampleData = [ - TransactionModel(amount: 100, date: Date(), type: .income, category: "Salary", note: "Monthly salary"), - TransactionModel(amount: 25.99, date: Date(), type: .expense, category: "Food", note: "Lunch"), - TransactionModel(amount: 50, date: Date(), type: .expense, category: "Transport", note: "Fuel") + 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 + ) ] -} \ No newline at end of file +} diff --git a/Coinly/Features/Models/TransactionType.swift b/Coinly/Features/Models/TransactionType.swift new file mode 100644 index 0000000..ee76a35 --- /dev/null +++ b/Coinly/Features/Models/TransactionType.swift @@ -0,0 +1,6 @@ +import Foundation + +enum TransactionType: String, Codable { + case income = "Income" + case expense = "Expense" +} diff --git a/Coinly/Features/Models/TransactionsStore.swift b/Coinly/Features/Models/TransactionsStore.swift index 097f5d5..07ea5e2 100644 --- a/Coinly/Features/Models/TransactionsStore.swift +++ b/Coinly/Features/Models/TransactionsStore.swift @@ -1,15 +1,14 @@ -// -// TransactionsStore.swift -// Coinly -// -// Created by Vadym Samoilenko on 02/03/2025. -// - - import Foundation class TransactionsStore: ObservableObject { - @Published var transactions: [TransactionModel] = TransactionModel.sampleData + @Published private(set) var transactions: [TransactionModel] = TransactionModel.sampleData + + init() { + NotificationCenter.default.addObserver(self, + selector: #selector(currencyDidChange), + name: .currencyDidChange, + object: nil) + } func addTransaction(_ transaction: TransactionModel) { transactions.insert(transaction, at: 0) @@ -18,4 +17,8 @@ class TransactionsStore: ObservableObject { func deleteTransaction(at indexSet: IndexSet) { transactions.remove(atOffsets: indexSet) } -} \ No newline at end of file + + @objc private func currencyDidChange() { + objectWillChange.send() + } +} diff --git a/Coinly/Features/Profile/ProfileView.swift b/Coinly/Features/Profile/ProfileView.swift index 724f65c..41092ce 100644 --- a/Coinly/Features/Profile/ProfileView.swift +++ b/Coinly/Features/Profile/ProfileView.swift @@ -1,27 +1,93 @@ -// -// ProfileView.swift -// Coinly -// -// Created by Vadym Samoilenko on 02/03/2025. -// - - import SwiftUI struct ProfileView: View { + @StateObject private var settings = AppSettings.shared + @Environment(\.colorScheme) var colorScheme + var body: some View { NavigationView { List { - Section("Settings") { - Text("Categories") - Text("Currency") - Text("Notifications") + 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() + } + } + + 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") { - Text("Help") - Text("Privacy Policy") - Text("Version 1.0") + 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) + } + } } } .navigationTitle("Profile") @@ -33,4 +99,4 @@ struct ProfileView_Previews: PreviewProvider { static var previews: some View { ProfileView() } -} \ No newline at end of file +} diff --git a/Coinly/Features/Transactions/AddTransactionView.swift b/Coinly/Features/Transactions/AddTransactionView.swift index 128450f..b006d7a 100644 --- a/Coinly/Features/Transactions/AddTransactionView.swift +++ b/Coinly/Features/Transactions/AddTransactionView.swift @@ -2,6 +2,7 @@ import SwiftUI struct AddTransactionView: View { @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var settings: AppSettings @State private var amount: String = "" @State private var note: String = "" @State private var category: String = CategoryModel.categories[0].name @@ -14,8 +15,12 @@ struct AddTransactionView: View { NavigationView { Form { Section("Amount") { - TextField("Amount", text: $amount) - .keyboardType(.decimalPad) + HStack { + Text(settings.currency.symbol) + .foregroundColor(.gray) + TextField("Amount", text: $amount) + .keyboardType(.decimalPad) + } } Section("Type") { @@ -60,7 +65,8 @@ struct AddTransactionView: View { date: date, type: type, category: category, - note: note.isEmpty ? nil : note + note: note.isEmpty ? nil : note, + originalCurrency: settings.currency // Используем текущую валюту из настроек ) addTransaction(transaction) dismiss() @@ -72,3 +78,10 @@ struct AddTransactionView: View { } } } + +struct AddTransactionView_Previews: PreviewProvider { + static var previews: some View { + AddTransactionView(addTransaction: { _ in }) + .environmentObject(AppSettings.shared) + } +} diff --git a/Coinly/UI/Components/TransactionRowView.swift b/Coinly/UI/Components/TransactionRowView.swift index e19c5b6..9a945f0 100644 --- a/Coinly/UI/Components/TransactionRowView.swift +++ b/Coinly/UI/Components/TransactionRowView.swift @@ -1,15 +1,8 @@ -// -// TransactionRowView.swift -// Coinly -// -// Created by Vadym Samoilenko on 02/03/2025. -// - - import SwiftUI struct TransactionRowView: View { let transaction: TransactionModel + @EnvironmentObject private var settings: AppSettings private var category: CategoryModel { CategoryModel.category(for: transaction.category) @@ -52,7 +45,7 @@ struct TransactionRowView: View { // Amount and Date VStack(alignment: .trailing, spacing: 4) { - Text(String(format: "%.2f", transaction.amount)) + Text(transaction.amountInCurrentCurrency().formatAsCurrency()) .font(.headline) .foregroundColor(transaction.isExpense ? .red : .green) Text(transaction.date, style: .date) @@ -69,5 +62,6 @@ struct TransactionRowView_Previews: PreviewProvider { TransactionRowView(transaction: TransactionModel.sampleData[0]) .previewLayout(.sizeThatFits) .padding() + .environmentObject(AppSettings.shared) } }