v2: Update account management with improved design and currency handling

- Add new color theme system
- Fix currency selection for all account types
- Update account icons and colors
- Improve account form validation
- Add proper error handling
- Fix total balance display
This commit is contained in:
“SamoilenkoVadym” 2025-03-03 14:41:02 +00:00
parent f77b5f149e
commit 94ca4853a2
27 changed files with 1310 additions and 302 deletions

View file

@ -1,20 +1,15 @@
//
// CoinlyApp.swift
// Coinly
//
// Created by Vadym Samoilenko on 03/03/2025.
//
import SwiftUI
@main
struct CoinlyApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
NavigationView {
ContentView()
}
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}
}
}

BIN
Coinly/Coinly/Assets.xcassets/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "image_fx_-2.jpg",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 KiB

Binary file not shown.

View file

@ -0,0 +1,56 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "89",
"green" : "199",
"red" : "52"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "89",
"green" : "199",
"red" : "52"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "88",
"green" : "209",
"red" : "48"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,56 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "48",
"green" : "59",
"red" : "255"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "48",
"green" : "59",
"red" : "255"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "58",
"green" : "69",
"red" : "255"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,56 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "255",
"green" : "255",
"red" : "255"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "255",
"green" : "255",
"red" : "255"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.950",
"blue" : "30",
"green" : "28",
"red" : "28"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,56 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.100",
"blue" : "0",
"green" : "0",
"red" : "0"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.100",
"blue" : "0",
"green" : "0",
"red" : "0"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.200",
"blue" : "0",
"green" : "0",
"red" : "0"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,56 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "247",
"green" : "242",
"red" : "242"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "247",
"green" : "242",
"red" : "242"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "30",
"green" : "28",
"red" : "28"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,41 @@
//
// Theme.swift
// Coinly
//
// Created by Vadym Samoilenko on 03/03/2025.
//
import SwiftUI
enum Theme {
// Основные цвета
static let background = Color(.systemGroupedBackground)
static let secondaryBackground = Color(.secondarySystemGroupedBackground)
// Цвета для карточек
static let cardBackground = Color(.systemBackground)
static let cardShadow = Color.black.opacity(0.05)
// Акцентные цвета
static let primary = Color.accentColor
static let positive = Color("AccentGreen", bundle: nil) // Fallback to system green
static let negative = Color("AccentRed", bundle: nil) // Fallback to system red
// Цвета для типов счетов
static let debitCard = Color.blue
static let credit = Color.purple
static let cash = Color.green
static let savings = Color.mint
// Цвета текста
static let primaryText = Color(.label)
static let secondaryText = Color(.secondaryLabel)
// Градиенты
static let primaryGradient = LinearGradient(
colors: [.blue, .blue.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}

View file

@ -0,0 +1,45 @@
import CoreData
extension AccountEntity {
// Convert CoreData entity to domain model
var toDomain: Account {
Account(
id: id ?? UUID(),
name: name ?? "",
type: AccountType(rawValue: typeRawValue ?? "") ?? .debitCard,
currency: Currency.all.first { $0.code == currencyCode } ?? Currency.current(),
balance: (balance as? Decimal) ?? 0,
initialBalance: (initialBalance as? Decimal) ?? 0,
creditLimit: creditLimit as? Decimal,
interestRate: interestRate as? Decimal,
icon: icon,
notes: notes,
isArchived: isArchived,
excludeFromStatistics: excludeFromStatistics,
createdAt: createdAt ?? Date(),
updatedAt: updatedAt ?? Date()
)
}
// Update entity from domain model
func update(from account: Account, context: NSManagedObjectContext) {
self.id = account.id
self.name = account.name
self.typeRawValue = account.type.rawValue
self.currencyCode = account.currency.code
self.balance = NSDecimalNumber(decimal: account.balance)
self.initialBalance = NSDecimalNumber(decimal: account.initialBalance)
if let creditLimit = account.creditLimit {
self.creditLimit = NSDecimalNumber(decimal: creditLimit)
}
if let interestRate = account.interestRate {
self.interestRate = NSDecimalNumber(decimal: interestRate)
}
self.icon = account.icon
self.notes = account.notes
self.isArchived = account.isArchived
self.excludeFromStatistics = account.excludeFromStatistics
self.createdAt = account.createdAt
self.updatedAt = account.updatedAt
}
}

View file

@ -1,9 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="1" systemVersion="11A491" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="false" userDefinedModelVersionIdentifier="">
<entity name="Item" representedClassName="Item" syncable="YES" codeGenerationType="class">
<attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23605" systemVersion="24D70" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="AccountEntity" representedClassName="AccountEntity" syncable="YES" codeGenerationType="class">
<attribute name="balance" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="creditLimit" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
<attribute name="currencyCode" optional="YES" attributeType="String"/>
<attribute name="excludeFromStatistics" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="icon" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="initialBalance" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
<attribute name="interestRate" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
<attribute name="isArchived" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="notes" optional="YES" attributeType="String"/>
<attribute name="typeRawValue" optional="YES" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
</entity>
<elements>
<element name="Item" positionX="-63" positionY="-18" width="128" height="44"/>
</elements>
</model>

View file

@ -0,0 +1,42 @@
//
// CoreDataStack.swift
// Coinly
//
// Created by Vadym Samoilenko on 03/03/2025.
//
import CoreData
final class CoreDataStack {
static let shared = CoreDataStack()
private init() {}
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "Coinly")
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Unable to load persistent stores: \(error)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
return container
}()
var viewContext: NSManagedObjectContext {
persistentContainer.viewContext
}
func saveContext() {
let context = persistentContainer.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
}

View file

@ -1,57 +1,51 @@
//
// Persistence.swift
// Coinly
//
// Created by Vadym Samoilenko on 03/03/2025.
//
import CoreData
struct PersistenceController {
static let shared = PersistenceController()
@MainActor
static let preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
for _ in 0..<10 {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
}
// MARK: - Preview Support
static var preview: PersistenceController = {
let controller = PersistenceController(inMemory: true)
let viewContext = controller.container.viewContext
// Create sample data
let account = AccountEntity(context: viewContext)
account.id = UUID()
account.name = "Sample Account"
account.typeRawValue = AccountType.debitCard.rawValue // Изменили с checking на debitCard
account.currencyCode = Currency.current().code
account.balance = NSDecimalNumber(decimal: Decimal(1000))
account.initialBalance = NSDecimalNumber(decimal: Decimal(1000))
account.createdAt = Date()
account.updatedAt = Date()
account.isArchived = false
account.excludeFromStatistics = false
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
return controller
}()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "Coinly")
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
container.loadPersistentStores { (storeDescription, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
}
container.viewContext.automaticallyMergesChangesFromParent = true
}
}

View file

@ -0,0 +1,63 @@
import CoreData
import Combine
protocol AccountRepositoryProtocol {
func getAccounts() async throws -> [Account]
func getAccount(by id: UUID) async throws -> Account?
func save(_ account: Account) async throws
func delete(_ account: Account) async throws
}
final class AccountRepository: AccountRepositoryProtocol {
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext = PersistenceController.shared.container.viewContext) {
self.context = context
}
func getAccounts() async throws -> [Account] {
let request = AccountEntity.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(keyPath: \AccountEntity.name, ascending: true)
]
let entities = try context.fetch(request)
print("Fetched entities: \(entities.count)") // Debug print
return entities.map { $0.toDomain }
}
func getAccount(by id: UUID) async throws -> Account? {
let request = AccountEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
request.fetchLimit = 1
let entities = try context.fetch(request)
return entities.first?.toDomain
}
func save(_ account: Account) async throws {
let request = AccountEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", account.id as CVarArg)
request.fetchLimit = 1
let entities = try context.fetch(request)
let entity = entities.first ?? AccountEntity(context: context)
entity.update(from: account, context: context)
try context.save()
print("Saved account: \(account.name)") // Debug print
}
func delete(_ account: Account) async throws {
let request = AccountEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", account.id as CVarArg)
request.fetchLimit = 1
let entities = try context.fetch(request)
if let entity = entities.first {
context.delete(entity)
try context.save()
print("Deleted account: \(account.name)") // Debug print
}
}
}

View file

@ -149,7 +149,7 @@ extension Account {
),
Account(
name: "Main Bank Account",
type: .checking,
type: .debitCard,
currency: .preview,
balance: 5000,
icon: "building.columns"

View file

@ -3,7 +3,7 @@ import Foundation
enum AccountType: String, CaseIterable, Identifiable, Codable {
// MARK: - Cases
case cash = "Cash" // Наличные
case checking = "Checking" // Текущий счет
case debitCard = "Debit Card" // Дебетовая карта
case savings = "Savings" // Сберегательный счет
case investmentAccount = "Investment" // Инвестиционный счет
case credit = "Credit" // Кредитная карта
@ -19,21 +19,21 @@ enum AccountType: String, CaseIterable, Identifiable, Codable {
var icon: String {
switch self {
case .cash: return "banknote"
case .checking: return "building.columns"
case .savings: return "sparkles"
case .debitCard: return "creditcard"
case .savings: return "building.columns.fill"
case .investmentAccount: return "chart.line.uptrend.xyaxis"
case .credit: return "creditcard"
case .loan: return "handshake"
case .deposit: return "safe"
case .debtToMe: return "arrow.left.circle"
case .myDebt: return "arrow.right.circle"
case .credit: return "creditcard.fill"
case .loan: return "dollarsign.circle.fill"
case .deposit: return "vault.fill"
case .debtToMe: return "arrow.left.circle.fill"
case .myDebt: return "arrow.right.circle.fill"
}
}
var color: String {
switch self {
case .cash: return "green"
case .checking: return "blue"
case .debitCard: return "blue"
case .savings: return "purple"
case .investmentAccount: return "orange"
case .credit: return "red"
@ -43,6 +43,7 @@ enum AccountType: String, CaseIterable, Identifiable, Codable {
case .myDebt: return "brown"
}
}
// MARK: - Business Logic
var allowsNegativeBalance: Bool {
@ -56,7 +57,7 @@ enum AccountType: String, CaseIterable, Identifiable, Codable {
var requiresInterestRate: Bool {
switch self {
case .credit, .loan, .deposit:
case .credit, .loan:
return true
default:
return false
@ -83,7 +84,7 @@ enum AccountType: String, CaseIterable, Identifiable, Codable {
// MARK: - Grouping
static var basicAccounts: [AccountType] {
[.cash, .checking, .savings]
[.cash, .debitCard, .savings]
}
static var investmentAccounts: [AccountType] {
@ -103,8 +104,8 @@ enum AccountType: String, CaseIterable, Identifiable, Codable {
switch self {
case .cash:
return NSLocalizedString("Physical money in your wallet", comment: "Cash description")
case .checking:
return NSLocalizedString("Everyday banking account", comment: "Checking description")
case .debitCard:
return NSLocalizedString("Your main bank card", comment: "Debit card description")
case .savings:
return NSLocalizedString("Account for saving money", comment: "Savings description")
case .investmentAccount:
@ -131,7 +132,7 @@ extension AccountType {
}
static var preview: AccountType {
.checking
.debitCard
}
}
#endif

View file

@ -40,16 +40,18 @@ extension Currency {
extension Currency {
/// Получение валюты для текущего региона или GBP по умолчанию
static func current(for locale: Locale = .current) -> Currency {
print("Getting currency for locale: \(locale.identifier)")
// Пробуем получить валюту из локали
if let currencyCode = locale.currency?.identifier,
let currency = all.first(where: { $0.code == currencyCode }) {
print("Found local currency: \(currencyCode)")
return currency
}
return defaultCurrency
}
/// Британский фунт как валюта по умолчанию
static var defaultCurrency: Currency {
Currency(
// Если не удалось определить, возвращаем GBP как дефолтную
print("Using default GBP currency")
return Currency(
code: "GBP",
numericCode: "826",
name: "British Pound Sterling",
@ -64,9 +66,10 @@ extension Currency {
let locales = Locale.availableIdentifiers.map(Locale.init)
return Locale.Currency.isoCurrencies.compactMap { currency in
guard let locale = locales.first(where: { $0.currency?.identifier == currency.identifier }) else { return nil }
guard let locale = locales.first(where: { $0.currency?.identifier == currency.identifier }),
let name = locale.localizedString(forCurrencyCode: currency.identifier)
else { return nil }
let name = locale.localizedString(forCurrencyCode: currency.identifier) ?? currency.identifier
let symbol = locale.currencySymbol ?? currency.identifier
return Currency(
@ -77,7 +80,8 @@ extension Currency {
fractionDigits: Currency.getFractionDigits(for: currency.identifier),
minorUnit: Currency.getMinorUnit(for: currency.identifier)
)
}.sorted { $0.code < $1.code }
}
.sorted { $0.code < $1.code }
}
private static func getFractionDigits(for currencyCode: String) -> Int {
@ -102,16 +106,17 @@ extension Currency {
extension Currency {
static var sampleData: [Currency] {
[
defaultCurrency,
Currency(code: "GBP", numericCode: "826", name: "British Pound Sterling", symbol: "£", fractionDigits: 2, minorUnit: 100),
Currency(code: "USD", numericCode: "840", name: "US Dollar", symbol: "$", fractionDigits: 2, minorUnit: 100),
Currency(code: "EUR", numericCode: "978", name: "Euro", symbol: "", fractionDigits: 2, minorUnit: 100),
Currency(code: "UAH", numericCode: "980", name: "Hryvnia", symbol: "", fractionDigits: 2, minorUnit: 100),
Currency(code: "JPY", numericCode: "392", name: "Yen", symbol: "¥", fractionDigits: 0, minorUnit: 1)
Currency(code: "JPY", numericCode: "392", name: "Japanese Yen", symbol: "¥", fractionDigits: 0, minorUnit: 1)
]
}
static var preview: Currency {
defaultCurrency
// GBP как дефолтная валюта для превью
sampleData[0]
}
}
#endif

View file

@ -1,21 +1,18 @@
//
// AccountViewModel.swift
// Coinly
//
// Created by Vadym Samoilenko on 03/03/2025.
//
import Foundation
import Combine
@MainActor
final class AccountViewModel: ObservableObject {
// MARK: - Published Properties
@Published private(set) var accounts: [Account] = []
@Published private(set) var isLoading = false
@Published private(set) var error: Error?
private let repository: AccountRepositoryProtocol
init(repository: AccountRepositoryProtocol = AccountRepository()) {
self.repository = repository
}
// MARK: - Computed Properties
var activeAccounts: [Account] {
accounts.filter { !$0.isArchived }
@ -33,7 +30,61 @@ final class AccountViewModel: ObservableObject {
Dictionary(grouping: activeAccounts) { $0.currency }
}
// MARK: - Total Balance Calculations
// MARK: - Account Operations
func loadAccounts() async {
isLoading = true
error = nil
do {
accounts = try await repository.getAccounts()
print("Loaded accounts: \(accounts.count)") // Debug print
} catch {
self.error = error
print("Error loading accounts: \(error)") // Debug print
}
isLoading = false
}
func addAccount(_ account: Account) async throws {
try await repository.save(account)
await loadAccounts()
print("Added new account: \(account.name)") // Debug print
}
func updateAccount(_ account: Account) async throws {
try await repository.save(account)
await loadAccounts()
print("Updated account: \(account.name)") // Debug print
}
func deleteAccount(_ account: Account) async throws {
try await repository.delete(account)
await loadAccounts()
print("Deleted account: \(account.name)") // Debug print
}
func archiveAccount(_ account: Account) async throws {
var updatedAccount = account
updatedAccount.archive()
try await updateAccount(updatedAccount)
}
func unarchiveAccount(_ account: Account) async throws {
var updatedAccount = account
updatedAccount.unarchive()
try await updateAccount(updatedAccount)
}
// MARK: - Filtering & Calculations
func accounts(for type: AccountType) -> [Account] {
activeAccounts.filter { $0.type == type }
}
func accounts(for currency: Currency) -> [Account] {
activeAccounts.filter { $0.currency == currency }
}
func totalBalance(for currency: Currency) -> Decimal {
activeAccounts
.filter { $0.currency == currency && !$0.excludeFromStatistics }
@ -50,81 +101,8 @@ final class AccountViewModel: ObservableObject {
.reduce(0) { $0 + $1.balance }
}
// MARK: - Account Operations
func addAccount(_ account: Account) async throws {
// TODO: Implement repository call
accounts.append(account)
}
func updateAccount(_ account: Account) async throws {
guard let index = accounts.firstIndex(where: { $0.id == account.id }) else {
throw AccountError.accountNotFound
}
accounts[index] = account
// TODO: Implement repository call
}
func deleteAccount(_ account: Account) async throws {
accounts.removeAll { $0.id == account.id }
// TODO: Implement repository call
}
func archiveAccount(_ account: Account) async throws {
var updatedAccount = account
updatedAccount.archive()
try await updateAccount(updatedAccount)
}
func unarchiveAccount(_ account: Account) async throws {
var updatedAccount = account
updatedAccount.unarchive()
try await updateAccount(updatedAccount)
}
// MARK: - Data Loading
func loadAccounts() async {
isLoading = true
error = nil
do {
// TODO: Implement repository call
// Temporary using sample data
accounts = Account.sampleData
} catch {
self.error = error
}
isLoading = false
}
// MARK: - Filtering
func accounts(for type: AccountType) -> [Account] {
activeAccounts.filter { $0.type == type }
}
func accounts(for currency: Currency) -> [Account] {
activeAccounts.filter { $0.currency == currency }
}
}
// MARK: - Errors
enum AccountError: LocalizedError {
case accountNotFound
case invalidAmount
case insufficientFunds
case exceedsCreditLimit
var errorDescription: String? {
switch self {
case .accountNotFound:
return NSLocalizedString("Account not found", comment: "")
case .invalidAmount:
return NSLocalizedString("Invalid amount", comment: "")
case .insufficientFunds:
return NSLocalizedString("Insufficient funds", comment: "")
case .exceedsCreditLimit:
return NSLocalizedString("Exceeds credit limit", comment: "")
}
func totalDebtFormatted(currency: Currency) -> String {
currency.format(totalDebt(for: currency))
}
}
@ -137,4 +115,4 @@ extension AccountViewModel {
return viewModel
}
}
#endif
#endif

View file

@ -0,0 +1,203 @@
//
// AccountDetailView.swift
// Coinly
//
// Created by Vadym Samoilenko on 03/03/2025.
//
import SwiftUI
struct AccountDetailView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = AccountViewModel()
@State private var showingEditSheet = false
@State private var showingDeleteAlert = false
@State private var showingArchiveAlert = false
let account: Account
var body: some View {
List {
// Balance Section
Section {
VStack(spacing: 8) {
Text(account.formattedBalance)
.font(.system(.title, design: .rounded))
.foregroundColor(balanceColor)
Text("Current Balance")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
if let creditLimit = account.creditLimit {
HStack {
Text("Credit Limit")
Spacer()
Text(account.currency.format(creditLimit))
}
HStack {
Text("Available Credit")
Spacer()
Text(account.formattedAvailableBalance)
.foregroundColor(account.availableBalance > 0 ? .green : .red)
}
}
if let interestRate = account.interestRate {
HStack {
Text("Interest Rate")
Spacer()
Text(formatInterestRate(interestRate))
}
}
}
// Account Details Section
Section("Account Details") {
DetailRow(title: "Name", value: account.name)
DetailRow(title: "Type", value: account.type.localizedName)
DetailRow(title: "Currency", value: account.currency.code)
if let notes = account.notes {
DetailRow(title: "Notes", value: notes)
}
if account.excludeFromStatistics {
Text("Excluded from Statistics")
.foregroundColor(.secondary)
}
}
// Statistics Section
Section("Statistics") {
DetailRow(
title: "Initial Balance",
value: account.currency.format(account.initialBalance)
)
DetailRow(
title: "Created",
value: formatDate(account.createdAt)
)
DetailRow(
title: "Last Modified",
value: formatDate(account.updatedAt)
)
}
// Actions Section
Section {
Button(action: { showingEditSheet = true }) {
Label("Edit Account", systemImage: "pencil")
}
Button(action: { showingArchiveAlert = true }) {
Label(
account.isArchived ? "Unarchive Account" : "Archive Account",
systemImage: account.isArchived ? "archivebox.circle" : "archivebox"
)
}
Button(role: .destructive, action: { showingDeleteAlert = true }) {
Label("Delete Account", systemImage: "trash")
}
}
}
.navigationTitle(account.name)
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showingEditSheet) {
AccountFormView(editingAccount: account)
}
.alert("Delete Account", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) { }
Button("Delete", role: .destructive) { deleteAccount() }
} message: {
Text("Are you sure you want to delete this account? This action cannot be undone.")
}
.alert(account.isArchived ? "Unarchive Account" : "Archive Account",
isPresented: $showingArchiveAlert) {
Button("Cancel", role: .cancel) { }
Button(account.isArchived ? "Unarchive" : "Archive") { toggleArchive() }
} message: {
Text(account.isArchived ?
"Do you want to unarchive this account?" :
"Archived accounts are hidden from the main view. You can unarchive them later.")
}
}
// MARK: - Computed Properties
private var balanceColor: Color {
if account.isOverdrawn {
return .red
}
return account.balance >= 0 ? .primary : .red
}
// MARK: - Actions
private func deleteAccount() {
Task {
try? await viewModel.deleteAccount(account)
dismiss()
}
}
private func toggleArchive() {
Task {
if account.isArchived {
try? await viewModel.unarchiveAccount(account)
} else {
try? await viewModel.archiveAccount(account)
}
dismiss()
}
}
// MARK: - Helper Methods
private func formatInterestRate(_ rate: Decimal) -> String {
let percentage = rate * 100
return String(format: "%.1f%%", NSDecimalNumber(decimal: percentage).doubleValue)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter.string(from: date)
}
}
// MARK: - Supporting Views
private struct DetailRow: View {
let title: String
let value: String
var body: some View {
HStack {
Text(title)
Spacer()
Text(value)
.foregroundColor(.secondary)
}
}
}
// MARK: - Preview
#if DEBUG
struct AccountDetailView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
AccountDetailView(account: .preview)
}
NavigationView {
AccountDetailView(account: Account.sampleData[3]) // Credit card preview
}
}
}
#endif

View file

@ -0,0 +1,247 @@
import SwiftUI
struct AccountFormView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.managedObjectContext) private var viewContext
@StateObject private var viewModel = AccountViewModel()
// Form State
@State private var name = ""
@State private var type = AccountType.debitCard
@State private var currency = Currency.current()
@State private var balance: Decimal? = 0
@State private var creditLimit: Decimal? = nil
@State private var interestRate: Decimal? = nil
@State private var notes = ""
@State private var icon: String? = nil
@State private var excludeFromStatistics = false
// Validation
@State private var showingErrors = false
@State private var errorMessage = ""
// Edit Mode
var editingAccount: Account?
var body: some View {
NavigationView {
Form {
// Basic Information
Section("Basic Information") {
TextField("Account Name", text: $name)
.textContentType(.name)
Picker("Account Type", selection: $type) {
ForEach(AccountType.allCases) { type in
Label(
type.localizedName,
systemImage: type.icon
).tag(type)
}
}
.onChange(of: type) { newType in
// Reset specific fields when type changes
if !newType.requiresInterestRate {
interestRate = nil
}
if newType != .credit {
creditLimit = nil
}
}
Picker("Currency", selection: $currency) {
ForEach(Currency.all) { currency in
Text("\(currency.code) - \(currency.localizedName())")
.tag(currency)
}
}
}
// Balance Section
Section("Balance") {
HStack {
Text("Initial Balance")
Spacer()
TextField("0",
value: Binding(
get: { balance ?? 0 },
set: { balance = $0 }
),
format: .currency(code: currency.code))
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
}
if type == .credit {
HStack {
Text("Credit Limit")
Spacer()
TextField("0",
value: Binding(
get: { creditLimit ?? 0 },
set: { creditLimit = $0 }
),
format: .currency(code: currency.code))
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
}
}
if type.requiresInterestRate {
HStack {
Text("Interest Rate")
Spacer()
TextField("0%",
value: Binding(
get: { interestRate ?? 0 },
set: { interestRate = $0 }
),
format: .percent)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
}
}
}
// Additional Settings
Section {
Toggle("Exclude from Statistics", isOn: $excludeFromStatistics)
TextField("Notes", text: $notes, axis: .vertical)
.lineLimit(3)
}
if showingErrors {
Section {
Text(errorMessage)
.foregroundColor(Theme.negative)
}
}
}
.navigationTitle(editingAccount == nil ? "New Account" : "Edit Account")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
save()
}
}
}
.onAppear {
if let account = editingAccount {
loadAccount(account)
} else {
// При создании нового счета устанавливаем валюту по умолчанию
currency = getDefaultCurrency()
}
}
}
}
// MARK: - Actions
private func save() {
guard validate() else { return }
print("Saving account: \(name)")
print("Balance: \(balance ?? 0)")
print("Currency: \(currency.code)")
let account = Account(
id: editingAccount?.id ?? UUID(),
name: name,
type: type,
currency: currency,
balance: balance ?? 0,
initialBalance: balance ?? 0,
creditLimit: type == .credit ? creditLimit : nil,
interestRate: type.requiresInterestRate ? interestRate : nil,
icon: icon,
notes: notes.isEmpty ? nil : notes,
excludeFromStatistics: excludeFromStatistics,
createdAt: editingAccount?.createdAt ?? Date(),
updatedAt: Date()
)
Task {
do {
if editingAccount != nil {
try await viewModel.updateAccount(account)
} else {
try await viewModel.addAccount(account)
}
print("Account saved successfully: \(account.name)")
await viewModel.loadAccounts()
dismiss()
} catch {
print("Error saving account: \(error)")
showError(error.localizedDescription)
}
}
}
private func loadAccount(_ account: Account) {
name = account.name
type = account.type
currency = account.currency
balance = account.balance
creditLimit = account.creditLimit
interestRate = account.interestRate
notes = account.notes ?? ""
icon = account.icon
excludeFromStatistics = account.excludeFromStatistics
}
private func getDefaultCurrency() -> Currency {
// Для всех типов счетов используем валюту региона или GBP по умолчанию
return Currency.current()
}
// MARK: - Validation
private func validate() -> Bool {
if name.trimmed.isEmpty {
showError("Please enter account name")
return false
}
if type == .credit && creditLimit == nil {
showError("Please enter credit limit")
return false
}
if type.requiresInterestRate && interestRate == nil {
showError("Please enter interest rate")
return false
}
return true
}
private func showError(_ message: String) {
errorMessage = message
showingErrors = true
}
}
// MARK: - Helpers
private extension String {
var trimmed: String {
self.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
// MARK: - Preview
#if DEBUG
struct AccountFormView_Previews: PreviewProvider {
static var previews: some View {
AccountFormView()
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
AccountFormView(editingAccount: Account.preview)
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
#endif

View file

@ -0,0 +1,163 @@
import SwiftUI
import CoreData
struct AccountListView: View {
@Environment(\.managedObjectContext) private var viewContext
@StateObject private var viewModel = AccountViewModel()
@State private var showingAddAccount = false
@State private var showArchived = false
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Total Balance Card
if !viewModel.activeAccounts.isEmpty {
totalBalanceCard
}
// Account Groups
VStack(spacing: 24) {
// Basic Accounts
ForEach(AccountType.basicAccounts, id: \.self) { type in
accountGroup(for: type)
}
// Investment Accounts
if !viewModel.accounts(for: .investmentAccount).isEmpty {
accountGroup(for: .investmentAccount)
}
// Credit & Debt Accounts
if hasDebtAccounts {
VStack(alignment: .leading, spacing: 16) {
Text("CREDIT & DEBT")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(Theme.secondaryText)
.padding(.horizontal, 16)
ForEach(AccountType.debtAccounts, id: \.self) { type in
if !viewModel.accounts(for: type).isEmpty {
accountGroup(for: type)
}
}
}
}
}
}
.padding(.top)
}
.background(Theme.background)
.navigationTitle("Accounts")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showingAddAccount = true }) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundColor(Theme.primary)
}
}
}
.sheet(isPresented: $showingAddAccount) {
AccountFormView()
.environment(\.managedObjectContext, viewContext)
}
.refreshable {
await viewModel.loadAccounts()
}
.task {
await viewModel.loadAccounts()
}
}
private var totalBalanceCard: some View {
VStack(spacing: 8) {
Text("Total Balance")
.font(.subheadline)
.foregroundColor(Theme.secondaryText)
ForEach(Array(viewModel.accountsByCurrency.keys), id: \.self) { currency in
VStack(spacing: 4) {
Text(viewModel.totalBalanceFormatted(currency: currency))
.font(.system(size: 34, weight: .medium, design: .rounded))
.foregroundColor(Theme.primaryText)
Text(currency.code)
.font(.caption)
.foregroundColor(Theme.secondaryText)
}
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
.background(
RoundedRectangle(cornerRadius: 24)
.fill(Theme.cardBackground)
.shadow(color: Theme.cardShadow, radius: 15, x: 0, y: 5)
)
.padding(.horizontal, 16)
}
private func accountGroup(for type: AccountType) -> some View {
let accounts = viewModel.accounts(for: type)
if accounts.isEmpty { return EmptyView().erasedToAnyView() }
return VStack(alignment: .leading, spacing: 16) {
// Group Header
HStack {
Image(systemName: type.icon)
.foregroundColor(accountTypeColor(for: type))
Text(type.localizedName.uppercased())
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(Theme.secondaryText)
}
.padding(.horizontal, 16)
// Account Cards
VStack(spacing: 12) {
ForEach(accounts) { account in
NavigationLink {
AccountDetailView(account: account)
.environment(\.managedObjectContext, viewContext)
} label: {
AccountRowView(account: account)
}
.buttonStyle(PlainButtonStyle())
}
}
}
}
private func accountTypeColor(for type: AccountType) -> Color {
switch type {
case .debitCard: return Theme.debitCard
case .credit: return Theme.credit
case .cash: return Theme.cash
case .savings: return Theme.savings
default: return Theme.primary
}
}
private var hasDebtAccounts: Bool {
AccountType.debtAccounts.contains { !viewModel.accounts(for: $0).isEmpty }
}
}
extension View {
func erasedToAnyView() -> AnyView {
AnyView(self)
}
}
// MARK: - Preview
#if DEBUG
struct AccountListView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
AccountListView()
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
}
#endif

View file

@ -1,11 +1,3 @@
//
// AccountRowView.swift
// Coinly
//
// Created by Vadym Samoilenko on 03/03/2025.
//
import SwiftUI
struct AccountRowView: View {
@ -14,30 +6,50 @@ struct AccountRowView: View {
var body: some View {
HStack(spacing: 16) {
// Icon
Image(systemName: account.icon ?? account.type.icon)
.font(.title2)
.foregroundColor(Color(account.type.color))
.frame(width: 32)
ZStack {
Circle()
.fill(iconBackgroundColor)
.frame(width: 48, height: 48)
Image(systemName: account.icon ?? account.type.icon)
.font(.system(size: 20))
.foregroundColor(.white)
}
// Account Info
VStack(alignment: .leading, spacing: 4) {
Text(account.name)
.font(.body)
.foregroundColor(.primary)
.font(.system(.body, design: .rounded))
.fontWeight(.medium)
.foregroundColor(Theme.primaryText)
HStack {
if account.type == .credit {
VStack(alignment: .leading, spacing: 2) {
Text(account.type.localizedName)
.font(.caption)
.foregroundColor(Theme.secondaryText)
HStack(spacing: 6) {
if let interestRate = account.interestRate {
Text(formatInterestRate(interestRate))
.font(.caption2)
.foregroundColor(Theme.secondaryText)
}
if let creditLimit = account.creditLimit {
Text("")
.font(.caption2)
.foregroundColor(Theme.secondaryText)
Text("Limit: \(account.currency.format(creditLimit))")
.font(.caption2)
.foregroundColor(Theme.secondaryText)
}
}
}
} else {
Text(account.type.localizedName)
.font(.caption)
.foregroundColor(.secondary)
if account.type.requiresInterestRate,
let interestRate = account.interestRate {
Text("")
.foregroundColor(.secondary)
Text(formatInterestRate(interestRate))
.font(.caption)
.foregroundColor(.secondary)
}
.foregroundColor(Theme.secondaryText)
}
}
@ -46,28 +58,45 @@ struct AccountRowView: View {
// Balance
VStack(alignment: .trailing, spacing: 4) {
Text(account.formattedBalance)
.font(.body)
.font(.system(.body, design: .rounded))
.fontWeight(.medium)
.foregroundColor(balanceColor)
if let creditLimit = account.creditLimit {
Text(formatAvailableCredit(creditLimit))
if account.type == .credit {
Text("Available: \(account.currency.format(account.creditLimit ?? 0 - account.balance))")
.font(.caption)
.foregroundColor(.secondary)
.foregroundColor(Theme.secondaryText)
}
}
}
.padding(.vertical, 8)
.padding(.vertical, 16)
.padding(.horizontal, 16)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(Theme.cardBackground)
.shadow(color: Theme.cardShadow, radius: 10, x: 0, y: 3)
)
.padding(.horizontal, 16)
.background(Color(UIColor.systemBackground))
.opacity(account.isArchived ? 0.6 : 1.0)
}
// MARK: - Computed Properties
private var balanceColor: Color {
if account.isOverdrawn {
return .red
if account.balance > 0 {
return Theme.positive
} else if account.balance < 0 {
return Theme.negative
}
return Theme.primaryText
}
private var iconBackgroundColor: Color {
switch account.type {
case .debitCard: return Theme.debitCard
case .credit: return Theme.credit
case .cash: return Theme.cash
case .savings: return Theme.savings
default: return Theme.primary
}
return account.balance >= 0 ? .primary : .red
}
// MARK: - Helper Methods
@ -75,14 +104,6 @@ struct AccountRowView: View {
let percentage = rate * 100
return String(format: "%.1f%%", NSDecimalNumber(decimal: percentage).doubleValue)
}
private func formatAvailableCredit(_ limit: Decimal) -> String {
let available = limit - account.balance
return String(
format: NSLocalizedString("Available: %@", comment: "Available credit"),
account.currency.format(available)
)
}
}
// MARK: - Preview
@ -90,30 +111,13 @@ struct AccountRowView: View {
struct AccountRowView_Previews: PreviewProvider {
static var previews: some View {
Group {
// Regular account
AccountRowView(account: Account.sampleData[0])
// Credit card
AccountRowView(account: Account.sampleData[3])
// Archived account
AccountRowView(account: Account(
name: "Archived Account",
type: .savings,
currency: .preview,
balance: 1000,
isArchived: true
))
// Overdrawn account
AccountRowView(account: Account(
name: "Overdrawn Account",
type: .checking,
currency: .preview,
balance: -500
))
.preferredColorScheme(.dark)
}
.previewLayout(.sizeThatFits)
.padding()
.background(Theme.background)
}
}
#endif
#endif

View file

@ -1,86 +1,16 @@
//
// ContentView.swift
// Coinly
//
// Created by Vadym Samoilenko on 03/03/2025.
//
import SwiftUI
import CoreData
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp!, formatter: itemFormatter)")
} label: {
Text(item.timestamp!, formatter: itemFormatter)
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
offsets.map { items[$0] }.forEach(viewContext.delete)
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
AccountListView()
.environment(\.managedObjectContext, viewContext)
}
}
private let itemFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .medium
return formatter
}()
#Preview {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
ContentView()
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}