Fix currency conversion calculations and total balance computation

This commit is contained in:
“SamoilenkoVadym” 2025-03-02 19:28:52 +00:00
parent 7586c78f55
commit 0001b77052
11 changed files with 312 additions and 80 deletions

View file

@ -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)
}
}

View file

@ -0,0 +1,8 @@
import Foundation
extension Double {
func formatAsCurrency() -> String {
let settings = AppSettings.shared
return String(format: "%@%.2f", settings.currency.symbol, self)
}
}

View file

@ -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)
}
}

View file

@ -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")
}

View file

@ -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
}
}
}

View file

@ -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
)
]
}
}

View file

@ -0,0 +1,6 @@
import Foundation
enum TransactionType: String, Codable {
case income = "Income"
case expense = "Expense"
}

View file

@ -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)
}
}
@objc private func currencyDidChange() {
objectWillChange.send()
}
}

View file

@ -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()
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}