Fix currency conversion calculations and total balance computation
This commit is contained in:
parent
7586c78f55
commit
0001b77052
11 changed files with 312 additions and 80 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
8
Coinly/Core/Utils/CurrencyFormatter.swift
Normal file
8
Coinly/Core/Utils/CurrencyFormatter.swift
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import Foundation
|
||||
|
||||
extension Double {
|
||||
func formatAsCurrency() -> String {
|
||||
let settings = AppSettings.shared
|
||||
return String(format: "%@%.2f", settings.currency.symbol, self)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
110
Coinly/Features/Models/AppSettings.swift
Normal file
110
Coinly/Features/Models/AppSettings.swift
Normal 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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
Coinly/Features/Models/TransactionType.swift
Normal file
6
Coinly/Features/Models/TransactionType.swift
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import Foundation
|
||||
|
||||
enum TransactionType: String, Codable {
|
||||
case income = "Income"
|
||||
case expense = "Expense"
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue