Update project: fix iOS 17 warnings and improve UI components

This commit is contained in:
“SamoilenkoVadym” 2025-03-03 01:22:28 +00:00
parent 607493fdd0
commit fcaf3c5105
52 changed files with 1941 additions and 2198 deletions

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

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

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

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -26,4 +26,15 @@ struct CategoryRowView: View {
Spacer()
}
}
}
}
#Preview {
CategoryRowView(category: CategoryModel(
name: "Food",
icon: "cart.fill",
color: "systemRed",
type: .expense,
isDefault: true
))
.padding()
}

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

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

View file

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

View file

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

View file

@ -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)
}
// Добавьте другие тесты по необходимости
}