Update project: fix iOS 17 warnings and improve UI components
This commit is contained in:
parent
607493fdd0
commit
fcaf3c5105
52 changed files with 1941 additions and 2198 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
20
Coinly/Core/Utils/AppCurrencyFormatter.swift
Normal file
20
Coinly/Core/Utils/AppCurrencyFormatter.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
extension Double {
|
||||
func formatAsCurrency() -> String {
|
||||
let settings = AppSettings.shared
|
||||
return String(format: "%@%.2f", settings.currency.symbol, self)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
42
Coinly/Features/Accounts/AccountCardView.swift
Normal file
42
Coinly/Features/Accounts/AccountCardView.swift
Normal file
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
Coinly/Features/Accounts/AccountPickerView.swift
Normal file
54
Coinly/Features/Accounts/AccountPickerView.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
93
Coinly/Features/Accounts/EditAccountView.swift
Normal file
93
Coinly/Features/Accounts/EditAccountView.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Coinly/Features/Budget/Models/BudgetModel.swift
Normal file
20
Coinly/Features/Budget/Models/BudgetModel.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
54
Coinly/Features/Budget/Views/BudgetProgressView.swift
Normal file
54
Coinly/Features/Budget/Views/BudgetProgressView.swift
Normal file
|
|
@ -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()
|
||||
}
|
||||
48
Coinly/Features/Budget/Views/BudgetSettingsView.swift
Normal file
48
Coinly/Features/Budget/Views/BudgetSettingsView.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
47
Coinly/Features/Budget/Views/CategoryBudgetEditView.swift
Normal file
47
Coinly/Features/Budget/Views/CategoryBudgetEditView.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
32
Coinly/Features/Budget/Views/CategoryBudgetRowView.swift
Normal file
32
Coinly/Features/Budget/Views/CategoryBudgetRowView.swift
Normal file
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
47
Coinly/Features/Budget/Views/MonthlyBudgetSettingsView.swift
Normal file
47
Coinly/Features/Budget/Views/MonthlyBudgetSettingsView.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
)
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCategories() {
|
||||
// TODO: Implement actual persistence
|
||||
}
|
||||
|
||||
private func loadBudgets() {
|
||||
// TODO: Implement actual persistence
|
||||
budgets = []
|
||||
}
|
||||
|
||||
private func saveBudgets() {
|
||||
// TODO: Implement actual persistence
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> = []
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
Coinly/Features/Transactions/TransactionsStore.swift
Normal file
76
Coinly/Features/Transactions/TransactionsStore.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
26
Coinly/UI/Components/CategoryIconView.swift
Normal file
26
Coinly/UI/Components/CategoryIconView.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,4 +26,15 @@ struct CategoryRowView: View {
|
|||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CategoryRowView(category: CategoryModel(
|
||||
name: "Food",
|
||||
icon: "cart.fill",
|
||||
color: "systemRed",
|
||||
type: .expense,
|
||||
isDefault: true
|
||||
))
|
||||
.padding()
|
||||
}
|
||||
|
|
|
|||
112
Coinly/UI/Components/ChartView.swift
Normal file
112
Coinly/UI/Components/ChartView.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
37
Coinly/UI/Components/CurrencyField.swift
Normal file
37
Coinly/UI/Components/CurrencyField.swift
Normal file
|
|
@ -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<Double>, 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
// Добавьте другие тесты по необходимости
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue