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:
parent
f77b5f149e
commit
94ca4853a2
27 changed files with 1310 additions and 302 deletions
Binary file not shown.
|
|
@ -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
BIN
Coinly/Coinly/Assets.xcassets/.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "image_fx_-2.jpg",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
|
|
|||
BIN
Coinly/Coinly/Assets.xcassets/AppIcon.appiconset/image_fx_-2.jpg
Normal file
BIN
Coinly/Coinly/Assets.xcassets/AppIcon.appiconset/image_fx_-2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 850 KiB |
BIN
Coinly/Coinly/Assets.xcassets/Colors/.DS_Store
vendored
Normal file
BIN
Coinly/Coinly/Assets.xcassets/Colors/.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
6
Coinly/Coinly/Assets.xcassets/Colors/Contents.json
Normal file
6
Coinly/Coinly/Assets.xcassets/Colors/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
41
Coinly/Coinly/Common/Theme/Theme.swift
Normal file
41
Coinly/Coinly/Common/Theme/Theme.swift
Normal 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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
42
Coinly/Coinly/Core/Data/CoreData/CoreDataStack.swift
Normal file
42
Coinly/Coinly/Core/Data/CoreData/CoreDataStack.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
63
Coinly/Coinly/Core/Data/Repositories/AccountRepository.swift
Normal file
63
Coinly/Coinly/Core/Data/Repositories/AccountRepository.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -149,7 +149,7 @@ extension Account {
|
|||
),
|
||||
Account(
|
||||
name: "Main Bank Account",
|
||||
type: .checking,
|
||||
type: .debitCard,
|
||||
currency: .preview,
|
||||
balance: 5000,
|
||||
icon: "building.columns"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
203
Coinly/Coinly/Features/Accounts/Views/AccountDetailView.swift
Normal file
203
Coinly/Coinly/Features/Accounts/Views/AccountDetailView.swift
Normal 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
|
||||
247
Coinly/Coinly/Features/Accounts/Views/AccountFormView.swift
Normal file
247
Coinly/Coinly/Features/Accounts/Views/AccountFormView.swift
Normal 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
|
||||
163
Coinly/Coinly/Features/Accounts/Views/AccountListView.swift
Normal file
163
Coinly/Coinly/Features/Accounts/Views/AccountListView.swift
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue