Compare commits
No commits in common. "v2-development" and "main" have entirely different histories.
v2-develop
...
main
102 changed files with 2906 additions and 4233 deletions
BIN
Coinly/.DS_Store
vendored
BIN
Coinly/.DS_Store
vendored
Binary file not shown.
19
Coinly/App/CoinlyApp.swift
Normal file
19
Coinly/App/CoinlyApp.swift
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct CoinlyApp: App {
|
||||||
|
@StateObject private var settings = AppSettings.shared
|
||||||
|
@StateObject private var transactionsStore = TransactionsStore.shared
|
||||||
|
@StateObject private var accountsStore = AccountsStore.shared
|
||||||
|
@StateObject private var categoryStore = CategoryStore.shared
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(settings)
|
||||||
|
.environmentObject(transactionsStore)
|
||||||
|
.environmentObject(accountsStore)
|
||||||
|
.environmentObject(categoryStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Coinly/App/ContentView.swift
Normal file
48
Coinly/App/ContentView.swift
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@StateObject private var accountsStore = AccountsStore.shared
|
||||||
|
@StateObject private var settings = AppSettings.shared
|
||||||
|
@StateObject private var transactionsStore = TransactionsStore.shared
|
||||||
|
@StateObject private var categoryStore = CategoryStore.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView {
|
||||||
|
NavigationView {
|
||||||
|
DashboardView()
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("Dashboard", systemImage: "chart.pie.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationView {
|
||||||
|
TransactionsView()
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("Transactions", systemImage: "arrow.left.arrow.right")
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationView {
|
||||||
|
AccountsListView()
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("Accounts", systemImage: "creditcard.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationView {
|
||||||
|
ProfileView()
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("Profile", systemImage: "person.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environmentObject(accountsStore)
|
||||||
|
.environmentObject(settings)
|
||||||
|
.environmentObject(transactionsStore)
|
||||||
|
.environmentObject(categoryStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
29
Coinly/App/Persistence.swift
Normal file
29
Coinly/App/Persistence.swift
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
//
|
||||||
|
// Persistence.swift
|
||||||
|
// Coinly
|
||||||
|
//
|
||||||
|
// Created by Vadym Samoilenko on 02/03/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
struct PersistenceController {
|
||||||
|
static let shared = PersistenceController()
|
||||||
|
|
||||||
|
let container: NSPersistentContainer
|
||||||
|
|
||||||
|
init(inMemory: Bool = false) {
|
||||||
|
container = NSPersistentContainer(name: "Coinly")
|
||||||
|
|
||||||
|
if inMemory {
|
||||||
|
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
|
||||||
|
}
|
||||||
|
|
||||||
|
container.loadPersistentStores { (storeDescription, error) in
|
||||||
|
if let error = error as NSError? {
|
||||||
|
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "image_fx_-2.jpg",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|
@ -1,599 +0,0 @@
|
||||||
// !$*UTF8*$!
|
|
||||||
{
|
|
||||||
archiveVersion = 1;
|
|
||||||
classes = {
|
|
||||||
};
|
|
||||||
objectVersion = 77;
|
|
||||||
objects = {
|
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
|
||||||
F7DA16952D76075B00FEF25A /* LocalAuthentication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F7DA16942D76075B00FEF25A /* LocalAuthentication.framework */; };
|
|
||||||
/* End PBXBuildFile section */
|
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
|
||||||
F75D6AD62D75CB060073F403 /* PBXContainerItemProxy */ = {
|
|
||||||
isa = PBXContainerItemProxy;
|
|
||||||
containerPortal = F75D6AB82D75CB050073F403 /* Project object */;
|
|
||||||
proxyType = 1;
|
|
||||||
remoteGlobalIDString = F75D6ABF2D75CB050073F403;
|
|
||||||
remoteInfo = Coinly;
|
|
||||||
};
|
|
||||||
F75D6AE02D75CB060073F403 /* PBXContainerItemProxy */ = {
|
|
||||||
isa = PBXContainerItemProxy;
|
|
||||||
containerPortal = F75D6AB82D75CB050073F403 /* Project object */;
|
|
||||||
proxyType = 1;
|
|
||||||
remoteGlobalIDString = F75D6ABF2D75CB050073F403;
|
|
||||||
remoteInfo = Coinly;
|
|
||||||
};
|
|
||||||
/* End PBXContainerItemProxy section */
|
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
|
||||||
F75D6AC02D75CB050073F403 /* Coinly.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Coinly.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
F75D6AD52D75CB060073F403 /* CoinlyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CoinlyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
F75D6ADF2D75CB060073F403 /* CoinlyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CoinlyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
F7DA16942D76075B00FEF25A /* LocalAuthentication.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LocalAuthentication.framework; path = System/Library/Frameworks/LocalAuthentication.framework; sourceTree = SDKROOT; };
|
|
||||||
/* End PBXFileReference section */
|
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
|
||||||
F7DA16972D76079A00FEF25A /* Exceptions for "Coinly" folder in "Coinly" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
);
|
|
||||||
target = F75D6ABF2D75CB050073F403 /* Coinly */;
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
|
||||||
F75D6AC22D75CB050073F403 /* Coinly */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
exceptions = (
|
|
||||||
F7DA16972D76079A00FEF25A /* Exceptions for "Coinly" folder in "Coinly" target */,
|
|
||||||
);
|
|
||||||
path = Coinly;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
F75D6AD82D75CB060073F403 /* CoinlyTests */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
path = CoinlyTests;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
F75D6AE22D75CB060073F403 /* CoinlyUITests */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
path = CoinlyUITests;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
|
||||||
F75D6ABD2D75CB050073F403 /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
F7DA16952D76075B00FEF25A /* LocalAuthentication.framework in Frameworks */,
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
F75D6AD22D75CB060073F403 /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
F75D6ADC2D75CB060073F403 /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXFrameworksBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
|
||||||
F75D6AB72D75CB050073F403 = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
F75D6AC22D75CB050073F403 /* Coinly */,
|
|
||||||
F75D6AD82D75CB060073F403 /* CoinlyTests */,
|
|
||||||
F75D6AE22D75CB060073F403 /* CoinlyUITests */,
|
|
||||||
F7DA16932D76075A00FEF25A /* Frameworks */,
|
|
||||||
F75D6AC12D75CB050073F403 /* Products */,
|
|
||||||
);
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
F75D6AC12D75CB050073F403 /* Products */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
F75D6AC02D75CB050073F403 /* Coinly.app */,
|
|
||||||
F75D6AD52D75CB060073F403 /* CoinlyTests.xctest */,
|
|
||||||
F75D6ADF2D75CB060073F403 /* CoinlyUITests.xctest */,
|
|
||||||
);
|
|
||||||
name = Products;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
F7DA16932D76075A00FEF25A /* Frameworks */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
F7DA16942D76075B00FEF25A /* LocalAuthentication.framework */,
|
|
||||||
);
|
|
||||||
name = Frameworks;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXGroup section */
|
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
|
||||||
F75D6ABF2D75CB050073F403 /* Coinly */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = F75D6AE92D75CB060073F403 /* Build configuration list for PBXNativeTarget "Coinly" */;
|
|
||||||
buildPhases = (
|
|
||||||
F75D6ABC2D75CB050073F403 /* Sources */,
|
|
||||||
F75D6ABD2D75CB050073F403 /* Frameworks */,
|
|
||||||
F75D6ABE2D75CB050073F403 /* Resources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
);
|
|
||||||
fileSystemSynchronizedGroups = (
|
|
||||||
F75D6AC22D75CB050073F403 /* Coinly */,
|
|
||||||
);
|
|
||||||
name = Coinly;
|
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = Coinly;
|
|
||||||
productReference = F75D6AC02D75CB050073F403 /* Coinly.app */;
|
|
||||||
productType = "com.apple.product-type.application";
|
|
||||||
};
|
|
||||||
F75D6AD42D75CB060073F403 /* CoinlyTests */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = F75D6AEC2D75CB060073F403 /* Build configuration list for PBXNativeTarget "CoinlyTests" */;
|
|
||||||
buildPhases = (
|
|
||||||
F75D6AD12D75CB060073F403 /* Sources */,
|
|
||||||
F75D6AD22D75CB060073F403 /* Frameworks */,
|
|
||||||
F75D6AD32D75CB060073F403 /* Resources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
F75D6AD72D75CB060073F403 /* PBXTargetDependency */,
|
|
||||||
);
|
|
||||||
fileSystemSynchronizedGroups = (
|
|
||||||
F75D6AD82D75CB060073F403 /* CoinlyTests */,
|
|
||||||
);
|
|
||||||
name = CoinlyTests;
|
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = CoinlyTests;
|
|
||||||
productReference = F75D6AD52D75CB060073F403 /* CoinlyTests.xctest */;
|
|
||||||
productType = "com.apple.product-type.bundle.unit-test";
|
|
||||||
};
|
|
||||||
F75D6ADE2D75CB060073F403 /* CoinlyUITests */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = F75D6AEF2D75CB060073F403 /* Build configuration list for PBXNativeTarget "CoinlyUITests" */;
|
|
||||||
buildPhases = (
|
|
||||||
F75D6ADB2D75CB060073F403 /* Sources */,
|
|
||||||
F75D6ADC2D75CB060073F403 /* Frameworks */,
|
|
||||||
F75D6ADD2D75CB060073F403 /* Resources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
F75D6AE12D75CB060073F403 /* PBXTargetDependency */,
|
|
||||||
);
|
|
||||||
fileSystemSynchronizedGroups = (
|
|
||||||
F75D6AE22D75CB060073F403 /* CoinlyUITests */,
|
|
||||||
);
|
|
||||||
name = CoinlyUITests;
|
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = CoinlyUITests;
|
|
||||||
productReference = F75D6ADF2D75CB060073F403 /* CoinlyUITests.xctest */;
|
|
||||||
productType = "com.apple.product-type.bundle.ui-testing";
|
|
||||||
};
|
|
||||||
/* End PBXNativeTarget section */
|
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
|
||||||
F75D6AB82D75CB050073F403 /* Project object */ = {
|
|
||||||
isa = PBXProject;
|
|
||||||
attributes = {
|
|
||||||
BuildIndependentTargetsInParallel = 1;
|
|
||||||
LastSwiftUpdateCheck = 1620;
|
|
||||||
LastUpgradeCheck = 1620;
|
|
||||||
TargetAttributes = {
|
|
||||||
F75D6ABF2D75CB050073F403 = {
|
|
||||||
CreatedOnToolsVersion = 16.2;
|
|
||||||
};
|
|
||||||
F75D6AD42D75CB060073F403 = {
|
|
||||||
CreatedOnToolsVersion = 16.2;
|
|
||||||
TestTargetID = F75D6ABF2D75CB050073F403;
|
|
||||||
};
|
|
||||||
F75D6ADE2D75CB060073F403 = {
|
|
||||||
CreatedOnToolsVersion = 16.2;
|
|
||||||
TestTargetID = F75D6ABF2D75CB050073F403;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
buildConfigurationList = F75D6ABB2D75CB050073F403 /* Build configuration list for PBXProject "Coinly" */;
|
|
||||||
developmentRegion = en;
|
|
||||||
hasScannedForEncodings = 0;
|
|
||||||
knownRegions = (
|
|
||||||
en,
|
|
||||||
Base,
|
|
||||||
);
|
|
||||||
mainGroup = F75D6AB72D75CB050073F403;
|
|
||||||
minimizedProjectReferenceProxies = 1;
|
|
||||||
preferredProjectObjectVersion = 77;
|
|
||||||
productRefGroup = F75D6AC12D75CB050073F403 /* Products */;
|
|
||||||
projectDirPath = "";
|
|
||||||
projectRoot = "";
|
|
||||||
targets = (
|
|
||||||
F75D6ABF2D75CB050073F403 /* Coinly */,
|
|
||||||
F75D6AD42D75CB060073F403 /* CoinlyTests */,
|
|
||||||
F75D6ADE2D75CB060073F403 /* CoinlyUITests */,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
/* End PBXProject section */
|
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
|
||||||
F75D6ABE2D75CB050073F403 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
F75D6AD32D75CB060073F403 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
F75D6ADD2D75CB060073F403 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXResourcesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
|
||||||
F75D6ABC2D75CB050073F403 /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
F75D6AD12D75CB060073F403 /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
F75D6ADB2D75CB060073F403 /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXSourcesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
|
||||||
F75D6AD72D75CB060073F403 /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
target = F75D6ABF2D75CB050073F403 /* Coinly */;
|
|
||||||
targetProxy = F75D6AD62D75CB060073F403 /* PBXContainerItemProxy */;
|
|
||||||
};
|
|
||||||
F75D6AE12D75CB060073F403 /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
target = F75D6ABF2D75CB050073F403 /* Coinly */;
|
|
||||||
targetProxy = F75D6AE02D75CB060073F403 /* PBXContainerItemProxy */;
|
|
||||||
};
|
|
||||||
/* End PBXTargetDependency section */
|
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
|
||||||
F75D6AE72D75CB060073F403 /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
|
||||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
|
||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_COMMA = YES;
|
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
|
||||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
|
||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
|
||||||
COPY_PHASE_STRIP = NO;
|
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
ENABLE_TESTABILITY = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_OPTIMIZATION_LEVEL = 0;
|
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
|
||||||
"DEBUG=1",
|
|
||||||
"$(inherited)",
|
|
||||||
);
|
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
|
||||||
MTL_FAST_MATH = YES;
|
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
F75D6AE82D75CB060073F403 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
|
||||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
|
||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_COMMA = YES;
|
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
|
||||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
|
||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
|
||||||
COPY_PHASE_STRIP = NO;
|
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
|
||||||
MTL_FAST_MATH = YES;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
|
||||||
VALIDATE_PRODUCT = YES;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
F75D6AEA2D75CB060073F403 /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
|
||||||
CODE_SIGN_ENTITLEMENTS = Coinly/Coinly.entitlements;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Coinly/Preview Content\"";
|
|
||||||
DEVELOPMENT_TEAM = DQDRL8F7U2;
|
|
||||||
ENABLE_PREVIEWS = YES;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_FILE = Coinly/Info.plist;
|
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "";
|
|
||||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "Coinly uses Face ID to securely access your financial data";
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.vadymsamoilenko.coinly.Coinly;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
F75D6AEB2D75CB060073F403 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
|
||||||
CODE_SIGN_ENTITLEMENTS = Coinly/Coinly.entitlements;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Coinly/Preview Content\"";
|
|
||||||
DEVELOPMENT_TEAM = DQDRL8F7U2;
|
|
||||||
ENABLE_PREVIEWS = YES;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_FILE = Coinly/Info.plist;
|
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "";
|
|
||||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "Coinly uses Face ID to securely access your financial data";
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.vadymsamoilenko.coinly.Coinly;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
F75D6AED2D75CB060073F403 /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = DQDRL8F7U2;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.vadymsamoilenko.coinly.CoinlyTests;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Coinly.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Coinly";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
F75D6AEE2D75CB060073F403 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = DQDRL8F7U2;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.vadymsamoilenko.coinly.CoinlyTests;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Coinly.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Coinly";
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
F75D6AF02D75CB060073F403 /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = DQDRL8F7U2;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.vadymsamoilenko.coinly.CoinlyUITests;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
TEST_TARGET_NAME = Coinly;
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
F75D6AF12D75CB060073F403 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = DQDRL8F7U2;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.vadymsamoilenko.coinly.CoinlyUITests;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
TEST_TARGET_NAME = Coinly;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
/* End XCBuildConfiguration section */
|
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
|
||||||
F75D6ABB2D75CB050073F403 /* Build configuration list for PBXProject "Coinly" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
F75D6AE72D75CB060073F403 /* Debug */,
|
|
||||||
F75D6AE82D75CB060073F403 /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
F75D6AE92D75CB060073F403 /* Build configuration list for PBXNativeTarget "Coinly" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
F75D6AEA2D75CB060073F403 /* Debug */,
|
|
||||||
F75D6AEB2D75CB060073F403 /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
F75D6AEC2D75CB060073F403 /* Build configuration list for PBXNativeTarget "CoinlyTests" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
F75D6AED2D75CB060073F403 /* Debug */,
|
|
||||||
F75D6AEE2D75CB060073F403 /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
F75D6AEF2D75CB060073F403 /* Build configuration list for PBXNativeTarget "CoinlyUITests" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
F75D6AF02D75CB060073F403 /* Debug */,
|
|
||||||
F75D6AF12D75CB060073F403 /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
/* End XCConfigurationList section */
|
|
||||||
};
|
|
||||||
rootObject = F75D6AB82D75CB050073F403 /* Project object */;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "self:">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
||||||
Binary file not shown.
|
|
@ -1,14 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>SchemeUserState</key>
|
|
||||||
<dict>
|
|
||||||
<key>Coinly.xcscheme_^#shared#^_</key>
|
|
||||||
<dict>
|
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>0</integer>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import UIKit
|
|
||||||
import UserNotifications
|
|
||||||
|
|
||||||
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
|
||||||
|
|
||||||
func application(
|
|
||||||
_ application: UIApplication,
|
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil
|
|
||||||
) -> Bool {
|
|
||||||
// Register notification categories
|
|
||||||
NotificationManager.NotificationCategory.registerCategories()
|
|
||||||
|
|
||||||
// Set notification delegate
|
|
||||||
UNUserNotificationCenter.current().delegate = self
|
|
||||||
|
|
||||||
// Request notification authorization
|
|
||||||
requestNotificationPermission()
|
|
||||||
|
|
||||||
// Register for remote notifications
|
|
||||||
application.registerForRemoteNotifications()
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func requestNotificationPermission() {
|
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { success, error in
|
|
||||||
if success {
|
|
||||||
print("Notification permission granted")
|
|
||||||
} else if let error = error {
|
|
||||||
print("Error requesting notification permission: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle successful registration of remote notifications
|
|
||||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
||||||
let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) }
|
|
||||||
let token = tokenParts.joined()
|
|
||||||
print("Device Token: \(token)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle failed registration of remote notifications
|
|
||||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
|
||||||
print("Failed to register for remote notifications: \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UNUserNotificationCenterDelegate
|
|
||||||
func userNotificationCenter(
|
|
||||||
_ center: UNUserNotificationCenter,
|
|
||||||
willPresent notification: UNNotification,
|
|
||||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
|
||||||
) {
|
|
||||||
completionHandler([.banner, .sound, .badge])
|
|
||||||
}
|
|
||||||
|
|
||||||
func userNotificationCenter(
|
|
||||||
_ center: UNUserNotificationCenter,
|
|
||||||
didReceive response: UNNotificationResponse,
|
|
||||||
withCompletionHandler completionHandler: @escaping () -> Void
|
|
||||||
) {
|
|
||||||
// Handle notification response
|
|
||||||
completionHandler()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
//
|
|
||||||
// AuthenticationView.swift
|
|
||||||
// Coinly
|
|
||||||
//
|
|
||||||
// Created by Vadym Samoilenko on 03/03/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import LocalAuthentication
|
|
||||||
|
|
||||||
struct AuthenticationView: View {
|
|
||||||
@Binding var isUnlocked: Bool
|
|
||||||
@EnvironmentObject private var settingsViewModel: SettingsViewModel
|
|
||||||
@State private var isAuthenticating = false
|
|
||||||
@State private var showError = false
|
|
||||||
@State private var errorMessage = ""
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 20) {
|
|
||||||
Image(systemName: "lock.shield")
|
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
|
|
||||||
Text("Welcome to Coinly")
|
|
||||||
.font(.title)
|
|
||||||
.fontWeight(.bold)
|
|
||||||
|
|
||||||
if settingsViewModel.settings.privacy.useBiometrics {
|
|
||||||
Button {
|
|
||||||
authenticate()
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: BiometricAuthManager.shared.biometricType == .faceID ? "faceid" : "touchid")
|
|
||||||
Text("Unlock with \(BiometricAuthManager.shared.biometricTypeName)")
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(Color.accentColor)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(10)
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert("Authentication Error", isPresented: $showError) {
|
|
||||||
Button("OK") { }
|
|
||||||
} message: {
|
|
||||||
Text(errorMessage)
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
if settingsViewModel.settings.privacy.useBiometrics {
|
|
||||||
authenticate()
|
|
||||||
} else {
|
|
||||||
isUnlocked = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func authenticate() {
|
|
||||||
guard !isAuthenticating else { return }
|
|
||||||
isAuthenticating = true
|
|
||||||
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let success = try await BiometricAuthManager.shared.authenticate()
|
|
||||||
await MainActor.run {
|
|
||||||
isUnlocked = success
|
|
||||||
isAuthenticating = false
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
showError = true
|
|
||||||
isAuthenticating = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AuthenticationView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
AuthenticationView(isUnlocked: .constant(false))
|
|
||||||
.environmentObject(SettingsViewModel())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct CoinlyApp: App {
|
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
|
||||||
let persistenceController = PersistenceController.shared
|
|
||||||
@StateObject private var settingsViewModel = SettingsViewModel()
|
|
||||||
@State private var isUnlocked = false
|
|
||||||
|
|
||||||
var body: some Scene {
|
|
||||||
WindowGroup {
|
|
||||||
if isUnlocked {
|
|
||||||
NavigationView {
|
|
||||||
ContentView()
|
|
||||||
}
|
|
||||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
|
||||||
.environmentObject(settingsViewModel)
|
|
||||||
.preferredColorScheme(colorScheme)
|
|
||||||
} else {
|
|
||||||
AuthenticationView(isUnlocked: $isUnlocked)
|
|
||||||
.environmentObject(settingsViewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var colorScheme: ColorScheme? {
|
|
||||||
switch settingsViewModel.settings.theme {
|
|
||||||
case .light:
|
|
||||||
return .light
|
|
||||||
case .dark:
|
|
||||||
return .dark
|
|
||||||
case .system:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
Coinly/Coinly/Assets.xcassets/.DS_Store
vendored
BIN
Coinly/Coinly/Assets.xcassets/.DS_Store
vendored
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 850 KiB |
BIN
Coinly/Coinly/Assets.xcassets/Colors/.DS_Store
vendored
BIN
Coinly/Coinly/Assets.xcassets/Colors/.DS_Store
vendored
Binary file not shown.
|
|
@ -1,56 +0,0 @@
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict/>
|
|
||||||
</plist>
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
//
|
|
||||||
// AnimationConstants.swift
|
|
||||||
// Coinly
|
|
||||||
//
|
|
||||||
// Created by Vadym Samoilenko on 03/03/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
enum AnimationConstants {
|
|
||||||
static let defaultAnimation = Animation.easeInOut(duration: 0.3)
|
|
||||||
static let springAnimation = Animation.spring(response: 0.3, dampingFraction: 0.8)
|
|
||||||
static let transitionAnimation = Animation.easeInOut(duration: 0.2)
|
|
||||||
|
|
||||||
static let defaultTransition = AnyTransition.opacity.combined(with: .move(edge: .trailing))
|
|
||||||
static let modalTransition = AnyTransition.asymmetric(
|
|
||||||
insertion: .move(edge: .bottom),
|
|
||||||
removal: .move(edge: .bottom)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
//
|
|
||||||
// BiometricAuthManager.swift
|
|
||||||
// Coinly
|
|
||||||
//
|
|
||||||
// Created by Vadym Samoilenko on 03/03/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
|
|
||||||
import LocalAuthentication
|
|
||||||
|
|
||||||
final class BiometricAuthManager {
|
|
||||||
static let shared = BiometricAuthManager()
|
|
||||||
|
|
||||||
private let context = LAContext()
|
|
||||||
private var error: NSError?
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
var biometricType: LABiometryType {
|
|
||||||
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
|
||||||
return context.biometryType
|
|
||||||
}
|
|
||||||
|
|
||||||
var biometricTypeName: String {
|
|
||||||
switch biometricType {
|
|
||||||
case .none:
|
|
||||||
return "None"
|
|
||||||
case .touchID:
|
|
||||||
return "Touch ID"
|
|
||||||
case .faceID:
|
|
||||||
return "Face ID"
|
|
||||||
@unknown default:
|
|
||||||
return "Biometric Authentication"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isAvailable: Bool {
|
|
||||||
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func authenticate() async throws -> Bool {
|
|
||||||
guard isAvailable else {
|
|
||||||
throw BiometricError.notAvailable
|
|
||||||
}
|
|
||||||
|
|
||||||
return try await context.evaluatePolicy(
|
|
||||||
.deviceOwnerAuthenticationWithBiometrics,
|
|
||||||
localizedReason: "Confirm your identity to access the app"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Errors
|
|
||||||
enum BiometricError: LocalizedError {
|
|
||||||
case notAvailable
|
|
||||||
case authFailed
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .notAvailable:
|
|
||||||
return "Biometric authentication is not available on this device"
|
|
||||||
case .authFailed:
|
|
||||||
return "Authentication failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
//
|
|
||||||
// NotificationManager.swift
|
|
||||||
// Coinly
|
|
||||||
//
|
|
||||||
// Created by Vadym Samoilenko on 03/03/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
|
|
||||||
import UserNotifications
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
final class NotificationManager {
|
|
||||||
static let shared = NotificationManager()
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
// MARK: - Permission Management
|
|
||||||
func requestPermissions() async throws {
|
|
||||||
let options: UNAuthorizationOptions = [.alert, .sound, .badge]
|
|
||||||
let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: options)
|
|
||||||
|
|
||||||
if granted {
|
|
||||||
await MainActor.run {
|
|
||||||
UIApplication.shared.registerForRemoteNotifications()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkPermissionStatus() async -> UNAuthorizationStatus {
|
|
||||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
|
||||||
return settings.authorizationStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Notification Scheduling
|
|
||||||
func scheduleDailyReminder(at time: Date, completion: @escaping (Error?) -> Void) {
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = "Daily Balance Check"
|
|
||||||
content.body = "Time to check your accounts and track your expenses!"
|
|
||||||
content.sound = .default
|
|
||||||
|
|
||||||
let calendar = Calendar.current
|
|
||||||
let components = calendar.dateComponents([.hour, .minute], from: time)
|
|
||||||
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
|
|
||||||
|
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: "dailyReminder",
|
|
||||||
content: content,
|
|
||||||
trigger: trigger
|
|
||||||
)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().add(request) { error in
|
|
||||||
completion(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func scheduleWeeklyReport(completion: @escaping (Error?) -> Void) {
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = "Weekly Financial Report"
|
|
||||||
content.body = "Your weekly spending report is ready to view!"
|
|
||||||
content.sound = .default
|
|
||||||
|
|
||||||
var dateComponents = DateComponents()
|
|
||||||
dateComponents.weekday = 1 // Sunday
|
|
||||||
dateComponents.hour = 9 // 9 AM
|
|
||||||
|
|
||||||
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
|
|
||||||
|
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: "weeklyReport",
|
|
||||||
content: content,
|
|
||||||
trigger: trigger
|
|
||||||
)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().add(request) { error in
|
|
||||||
completion(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func scheduleTransactionAlert(for amount: Decimal, in currency: Currency) {
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = "Large Transaction Alert"
|
|
||||||
content.body = "A transaction of \(currency.format(amount)) has been recorded."
|
|
||||||
content.sound = .default
|
|
||||||
|
|
||||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
|
||||||
|
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: UUID().uuidString,
|
|
||||||
content: content,
|
|
||||||
trigger: trigger
|
|
||||||
)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().add(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Notification Management
|
|
||||||
func removeAllPendingNotifications() {
|
|
||||||
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
|
||||||
}
|
|
||||||
|
|
||||||
func removePendingNotification(withIdentifier identifier: String) {
|
|
||||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Notification Categories
|
|
||||||
extension NotificationManager {
|
|
||||||
enum NotificationCategory: String {
|
|
||||||
case dailyReminder
|
|
||||||
case weeklyReport
|
|
||||||
case transactionAlert
|
|
||||||
|
|
||||||
static func registerCategories() {
|
|
||||||
let categories: Set<UNNotificationCategory> = [
|
|
||||||
UNNotificationCategory(
|
|
||||||
identifier: NotificationCategory.dailyReminder.rawValue,
|
|
||||||
actions: [],
|
|
||||||
intentIdentifiers: [],
|
|
||||||
options: .customDismissAction
|
|
||||||
),
|
|
||||||
UNNotificationCategory(
|
|
||||||
identifier: NotificationCategory.weeklyReport.rawValue,
|
|
||||||
actions: [],
|
|
||||||
intentIdentifiers: [],
|
|
||||||
options: .customDismissAction
|
|
||||||
),
|
|
||||||
UNNotificationCategory(
|
|
||||||
identifier: NotificationCategory.transactionAlert.rawValue,
|
|
||||||
actions: [],
|
|
||||||
intentIdentifiers: [],
|
|
||||||
options: .customDismissAction
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().setNotificationCategories(categories)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
// Common/Theme/Theme.swift
|
|
||||||
|
|
||||||
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)
|
|
||||||
static let negative = Color("AccentRed", bundle: nil)
|
|
||||||
|
|
||||||
// Цвета для типов счетов
|
|
||||||
static let cash = Color.green
|
|
||||||
static let debitCard = Color.blue
|
|
||||||
static let savings = Color.purple
|
|
||||||
static let investment = Color.orange
|
|
||||||
static let credit = Color.red
|
|
||||||
static let loan = Color.pink
|
|
||||||
static let deposit = Color.yellow
|
|
||||||
static let debtToMe = Color.mint
|
|
||||||
static let myDebt = Color.brown
|
|
||||||
|
|
||||||
// Цвета текста
|
|
||||||
static let primaryText = Color(.label)
|
|
||||||
static let secondaryText = Color(.secondaryLabel)
|
|
||||||
|
|
||||||
// Градиенты
|
|
||||||
static let primaryGradient = LinearGradient(
|
|
||||||
colors: [.blue, .blue.opacity(0.8)],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
//
|
|
||||||
// SlideTransitionView.swift
|
|
||||||
// Coinly
|
|
||||||
//
|
|
||||||
// Created by Vadym Samoilenko on 03/03/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct SlideTransitionView<Content: View>: View {
|
|
||||||
let content: Content
|
|
||||||
@Binding var isPresented: Bool
|
|
||||||
let edge: Edge
|
|
||||||
|
|
||||||
init(isPresented: Binding<Bool>, edge: Edge = .trailing, @ViewBuilder content: () -> Content) {
|
|
||||||
self._isPresented = isPresented
|
|
||||||
self.edge = edge
|
|
||||||
self.content = content()
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { geometry in
|
|
||||||
ZStack {
|
|
||||||
if isPresented {
|
|
||||||
content
|
|
||||||
.transition(.move(edge: edge))
|
|
||||||
.animation(AnimationConstants.defaultAnimation, value: isPresented)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FadeTransitionView<Content: View>: View {
|
|
||||||
let content: Content
|
|
||||||
@Binding var isPresented: Bool
|
|
||||||
|
|
||||||
init(isPresented: Binding<Bool>, @ViewBuilder content: () -> Content) {
|
|
||||||
self._isPresented = isPresented
|
|
||||||
self.content = content()
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
content
|
|
||||||
.opacity(isPresented ? 1 : 0)
|
|
||||||
.animation(AnimationConstants.defaultAnimation, value: isPresented)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
//
|
|
||||||
// AppStateManager.swift
|
|
||||||
// Coinly
|
|
||||||
//
|
|
||||||
// Created by Vadym Samoilenko on 03/03/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
final class AppStateManager: ObservableObject {
|
|
||||||
// MARK: - Published Properties
|
|
||||||
@Published private(set) var isAuthenticated: Bool
|
|
||||||
@Published private(set) var currentUser: User?
|
|
||||||
@Published var hasCompletedOnboarding: Bool
|
|
||||||
|
|
||||||
// MARK: - Properties
|
|
||||||
private let userDefaults: UserDefaults
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
// MARK: - Keys
|
|
||||||
private enum Keys {
|
|
||||||
static let hasCompletedOnboarding = "hasCompletedOnboarding"
|
|
||||||
static let lastLoginDate = "lastLoginDate"
|
|
||||||
static let authToken = "authToken"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
init(userDefaults: UserDefaults = .standard) {
|
|
||||||
self.userDefaults = userDefaults
|
|
||||||
self.isAuthenticated = userDefaults.string(forKey: Keys.authToken) != nil
|
|
||||||
self.hasCompletedOnboarding = userDefaults.bool(forKey: Keys.hasCompletedOnboarding)
|
|
||||||
|
|
||||||
// Автоматическое сохранение состояния onboarding
|
|
||||||
$hasCompletedOnboarding
|
|
||||||
.sink { [weak self] completed in
|
|
||||||
self?.userDefaults.set(completed, forKey: Keys.hasCompletedOnboarding)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Authentication Methods
|
|
||||||
func signIn(with user: User, token: String) {
|
|
||||||
currentUser = user
|
|
||||||
isAuthenticated = true
|
|
||||||
userDefaults.set(token, forKey: Keys.authToken)
|
|
||||||
userDefaults.set(Date(), forKey: Keys.lastLoginDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
func signOut() {
|
|
||||||
currentUser = nil
|
|
||||||
isAuthenticated = false
|
|
||||||
userDefaults.removeObject(forKey: Keys.authToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Session Management
|
|
||||||
func validateSession() -> Bool {
|
|
||||||
guard let lastLogin = userDefaults.object(forKey: Keys.lastLoginDate) as? Date else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Сессия валидна 30 дней
|
|
||||||
return Date().timeIntervalSince(lastLogin) < (30 * 24 * 60 * 60)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - User Model
|
|
||||||
struct User: Codable {
|
|
||||||
let id: String
|
|
||||||
let email: String
|
|
||||||
let name: String?
|
|
||||||
let createdAt: Date
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
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,8 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>_XCCurrentVersionName</key>
|
|
||||||
<string>Coinly.xcdatamodel</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
||||||
<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>
|
|
||||||
</model>
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
//
|
|
||||||
// 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,51 +0,0 @@
|
||||||
import CoreData
|
|
||||||
|
|
||||||
struct PersistenceController {
|
|
||||||
static let shared = PersistenceController()
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
let nsError = error as NSError
|
|
||||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 { (storeDescription, error) in
|
|
||||||
if let error = error as NSError? {
|
|
||||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
//
|
|
||||||
// SettingsRepositoryProtocol.swift
|
|
||||||
// Coinly
|
|
||||||
//
|
|
||||||
// Created by Vadym Samoilenko on 03/03/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
protocol SettingsRepositoryProtocol {
|
|
||||||
var settings: AppSettings { get }
|
|
||||||
var settingsPublisher: AnyPublisher<AppSettings, Never> { get }
|
|
||||||
|
|
||||||
func updateSettings(_ settings: AppSettings) throws
|
|
||||||
func resetSettings() throws
|
|
||||||
func checkForUpdates() async throws -> Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
final class SettingsRepository: SettingsRepositoryProtocol {
|
|
||||||
private enum Constants {
|
|
||||||
static let settingsKey = "app_settings"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Properties
|
|
||||||
private let userDefaults: UserDefaults
|
|
||||||
private let settingsSubject: CurrentValueSubject<AppSettings, Never>
|
|
||||||
|
|
||||||
var settings: AppSettings {
|
|
||||||
settingsSubject.value
|
|
||||||
}
|
|
||||||
|
|
||||||
var settingsPublisher: AnyPublisher<AppSettings, Never> {
|
|
||||||
settingsSubject.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
init(userDefaults: UserDefaults = .standard) {
|
|
||||||
self.userDefaults = userDefaults
|
|
||||||
|
|
||||||
// Load saved settings or use defaults
|
|
||||||
if let data = userDefaults.data(forKey: Constants.settingsKey),
|
|
||||||
let settings = try? JSONDecoder().decode(AppSettings.self, from: data) {
|
|
||||||
self.settingsSubject = CurrentValueSubject(settings)
|
|
||||||
} else {
|
|
||||||
self.settingsSubject = CurrentValueSubject(AppSettings.defaultSettings())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Methods
|
|
||||||
func updateSettings(_ settings: AppSettings) throws {
|
|
||||||
let data = try JSONEncoder().encode(settings)
|
|
||||||
userDefaults.set(data, forKey: Constants.settingsKey)
|
|
||||||
settingsSubject.send(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetSettings() throws {
|
|
||||||
let defaultSettings = AppSettings.defaultSettings()
|
|
||||||
try updateSettings(defaultSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkForUpdates() async throws -> Bool {
|
|
||||||
// Здесь будет реализация проверки обновлений
|
|
||||||
// Например, через API App Store
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
//
|
|
||||||
// Account.swift
|
|
||||||
// Coinly
|
|
||||||
//
|
|
||||||
// Created by Vadym Samoilenko on 03/03/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct Account: Identifiable, Hashable, Codable {
|
|
||||||
// MARK: - Properties
|
|
||||||
let id: UUID
|
|
||||||
var name: String
|
|
||||||
var type: AccountType
|
|
||||||
var currency: Currency
|
|
||||||
var balance: Decimal
|
|
||||||
var initialBalance: Decimal
|
|
||||||
var creditLimit: Decimal?
|
|
||||||
var interestRate: Decimal?
|
|
||||||
var maturityDate: Date?
|
|
||||||
var icon: String?
|
|
||||||
var notes: String?
|
|
||||||
var isArchived: Bool
|
|
||||||
var excludeFromStatistics: Bool
|
|
||||||
let createdAt: Date
|
|
||||||
var updatedAt: Date
|
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
|
||||||
var availableBalance: Decimal {
|
|
||||||
switch type {
|
|
||||||
case .credit:
|
|
||||||
return (creditLimit ?? 0) - balance
|
|
||||||
default:
|
|
||||||
return balance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isOverdrawn: Bool {
|
|
||||||
if type.allowsNegativeBalance {
|
|
||||||
return balance > (creditLimit ?? 0)
|
|
||||||
}
|
|
||||||
return balance < 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var formattedBalance: String {
|
|
||||||
currency.format(balance)
|
|
||||||
}
|
|
||||||
|
|
||||||
var formattedAvailableBalance: String {
|
|
||||||
currency.format(availableBalance)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
init(
|
|
||||||
id: UUID = UUID(),
|
|
||||||
name: String,
|
|
||||||
type: AccountType,
|
|
||||||
currency: Currency,
|
|
||||||
balance: Decimal = 0,
|
|
||||||
initialBalance: Decimal = 0,
|
|
||||||
creditLimit: Decimal? = nil,
|
|
||||||
interestRate: Decimal? = nil,
|
|
||||||
maturityDate: Date? = nil,
|
|
||||||
icon: String? = nil,
|
|
||||||
notes: String? = nil,
|
|
||||||
isArchived: Bool = false,
|
|
||||||
excludeFromStatistics: Bool = false,
|
|
||||||
createdAt: Date = Date(),
|
|
||||||
updatedAt: Date = Date()
|
|
||||||
) {
|
|
||||||
self.id = id
|
|
||||||
self.name = name
|
|
||||||
self.type = type
|
|
||||||
self.currency = currency
|
|
||||||
self.balance = balance
|
|
||||||
self.initialBalance = initialBalance
|
|
||||||
self.creditLimit = creditLimit
|
|
||||||
self.interestRate = interestRate
|
|
||||||
self.maturityDate = maturityDate
|
|
||||||
self.icon = icon
|
|
||||||
self.notes = notes
|
|
||||||
self.isArchived = isArchived
|
|
||||||
self.excludeFromStatistics = excludeFromStatistics
|
|
||||||
self.createdAt = createdAt
|
|
||||||
self.updatedAt = updatedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Business Logic
|
|
||||||
extension Account {
|
|
||||||
mutating func updateBalance(_ newBalance: Decimal) {
|
|
||||||
balance = newBalance
|
|
||||||
updatedAt = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func archive() {
|
|
||||||
isArchived = true
|
|
||||||
updatedAt = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func unarchive() {
|
|
||||||
isArchived = false
|
|
||||||
updatedAt = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateTransaction(_ amount: Decimal) -> Bool {
|
|
||||||
if type.allowsNegativeBalance {
|
|
||||||
return balance - amount <= (creditLimit ?? 0)
|
|
||||||
}
|
|
||||||
return balance - amount >= 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Grouping & Filtering
|
|
||||||
extension Account {
|
|
||||||
var isActive: Bool { !isArchived }
|
|
||||||
|
|
||||||
static func filterByType(_ accounts: [Account], type: AccountType) -> [Account] {
|
|
||||||
accounts.filter { $0.type == type }
|
|
||||||
}
|
|
||||||
|
|
||||||
static func filterActive(_ accounts: [Account]) -> [Account] {
|
|
||||||
accounts.filter { !$0.isArchived }
|
|
||||||
}
|
|
||||||
|
|
||||||
static func groupByCurrency(_ accounts: [Account]) -> [Currency: [Account]] {
|
|
||||||
Dictionary(grouping: accounts) { $0.currency }
|
|
||||||
}
|
|
||||||
|
|
||||||
static func totalBalance(_ accounts: [Account], in currency: Currency) -> Decimal {
|
|
||||||
accounts
|
|
||||||
.filter { $0.currency == currency && !$0.excludeFromStatistics }
|
|
||||||
.reduce(0) { $0 + $1.balance }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preview Helpers
|
|
||||||
#if DEBUG
|
|
||||||
extension Account {
|
|
||||||
static var sampleData: [Account] {
|
|
||||||
[
|
|
||||||
Account(
|
|
||||||
name: "Cash Wallet",
|
|
||||||
type: .cash,
|
|
||||||
currency: .preview,
|
|
||||||
balance: 1000,
|
|
||||||
icon: "wallet"
|
|
||||||
),
|
|
||||||
Account(
|
|
||||||
name: "Main Bank Account",
|
|
||||||
type: .debitCard,
|
|
||||||
currency: .preview,
|
|
||||||
balance: 5000,
|
|
||||||
icon: "building.columns"
|
|
||||||
),
|
|
||||||
Account(
|
|
||||||
name: "Savings",
|
|
||||||
type: .savings,
|
|
||||||
currency: .preview,
|
|
||||||
balance: 10000,
|
|
||||||
interestRate: 0.02,
|
|
||||||
icon: "sparkles"
|
|
||||||
),
|
|
||||||
Account(
|
|
||||||
name: "Credit Card",
|
|
||||||
type: .credit,
|
|
||||||
currency: .preview,
|
|
||||||
balance: 500,
|
|
||||||
creditLimit: 2000,
|
|
||||||
interestRate: 0.199,
|
|
||||||
icon: "creditcard"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
static var preview: Account {
|
|
||||||
sampleData[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum AccountType: String, CaseIterable, Identifiable, Codable {
|
|
||||||
// MARK: - Cases
|
|
||||||
case cash = "Cash" // Наличные
|
|
||||||
case debitCard = "Debit Card" // Дебетовая карта
|
|
||||||
case savings = "Savings" // Сберегательный счет
|
|
||||||
case investmentAccount = "Investment" // Инвестиционный счет
|
|
||||||
case credit = "Credit" // Кредитная карта
|
|
||||||
case loan = "Loan" // Кредит
|
|
||||||
case deposit = "Deposit" // Депозит
|
|
||||||
case debtToMe = "Debt to me" // Долг мне
|
|
||||||
case myDebt = "My debt" // Мой долг
|
|
||||||
|
|
||||||
// MARK: - Identifiable
|
|
||||||
var id: String { rawValue }
|
|
||||||
|
|
||||||
// MARK: - UI Properties
|
|
||||||
var icon: String {
|
|
||||||
switch self {
|
|
||||||
case .cash: return "banknote"
|
|
||||||
case .debitCard: return "creditcard"
|
|
||||||
case .savings: return "building.columns.fill"
|
|
||||||
case .investmentAccount: return "chart.line.uptrend.xyaxis"
|
|
||||||
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 .debitCard: return "blue"
|
|
||||||
case .savings: return "purple"
|
|
||||||
case .investmentAccount: return "orange"
|
|
||||||
case .credit: return "red"
|
|
||||||
case .loan: return "pink"
|
|
||||||
case .deposit: return "yellow"
|
|
||||||
case .debtToMe: return "mint"
|
|
||||||
case .myDebt: return "brown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Business Logic
|
|
||||||
var allowsNegativeBalance: Bool {
|
|
||||||
switch self {
|
|
||||||
case .credit, .loan, .myDebt:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var requiresInterestRate: Bool {
|
|
||||||
switch self {
|
|
||||||
case .credit, .loan:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isDebt: Bool {
|
|
||||||
switch self {
|
|
||||||
case .credit, .loan, .myDebt:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var requiresMaturityDate: Bool {
|
|
||||||
switch self {
|
|
||||||
case .loan, .deposit:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Grouping
|
|
||||||
static var basicAccounts: [AccountType] {
|
|
||||||
[.cash, .debitCard, .savings]
|
|
||||||
}
|
|
||||||
|
|
||||||
static var investmentAccounts: [AccountType] {
|
|
||||||
[.investmentAccount, .deposit]
|
|
||||||
}
|
|
||||||
|
|
||||||
static var debtAccounts: [AccountType] {
|
|
||||||
[.credit, .loan, .debtToMe, .myDebt]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Localization
|
|
||||||
var localizedName: String {
|
|
||||||
NSLocalizedString(rawValue, comment: "Account type")
|
|
||||||
}
|
|
||||||
|
|
||||||
var localizedDescription: String {
|
|
||||||
switch self {
|
|
||||||
case .cash:
|
|
||||||
return NSLocalizedString("Physical money in your wallet", comment: "Cash 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:
|
|
||||||
return NSLocalizedString("Investment portfolio or trading account", comment: "Investment description")
|
|
||||||
case .credit:
|
|
||||||
return NSLocalizedString("Credit card account", comment: "Credit description")
|
|
||||||
case .loan:
|
|
||||||
return NSLocalizedString("Long-term loan or mortgage", comment: "Loan description")
|
|
||||||
case .deposit:
|
|
||||||
return NSLocalizedString("Fixed-term deposit with interest", comment: "Deposit description")
|
|
||||||
case .debtToMe:
|
|
||||||
return NSLocalizedString("Money someone owes you", comment: "Debt to me description")
|
|
||||||
case .myDebt:
|
|
||||||
return NSLocalizedString("Money you owe to someone", comment: "My debt description")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preview Helpers
|
|
||||||
#if DEBUG
|
|
||||||
extension AccountType {
|
|
||||||
static var sampleData: [AccountType] {
|
|
||||||
AccountType.allCases
|
|
||||||
}
|
|
||||||
|
|
||||||
static var preview: AccountType {
|
|
||||||
.debitCard
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct AppSettings: Codable {
|
|
||||||
// MARK: - Authentication
|
|
||||||
var isAuthenticated: Bool
|
|
||||||
var userEmail: String?
|
|
||||||
|
|
||||||
// MARK: - Appearance
|
|
||||||
enum AppTheme: String, Codable, CaseIterable {
|
|
||||||
case light = "Light"
|
|
||||||
case dark = "Dark"
|
|
||||||
case system = "System"
|
|
||||||
|
|
||||||
var colorScheme: ColorScheme? {
|
|
||||||
switch self {
|
|
||||||
case .light: return .light
|
|
||||||
case .dark: return .dark
|
|
||||||
case .system: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var icon: String {
|
|
||||||
switch self {
|
|
||||||
case .light: return "sun.max.fill"
|
|
||||||
case .dark: return "moon.fill"
|
|
||||||
case .system: return "iphone"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var theme: AppTheme
|
|
||||||
|
|
||||||
// MARK: - Localization
|
|
||||||
enum AppLanguage: String, Codable, CaseIterable {
|
|
||||||
case english = "en"
|
|
||||||
|
|
||||||
var displayName: String {
|
|
||||||
switch self {
|
|
||||||
case .english: return "English"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var icon: String {
|
|
||||||
switch self {
|
|
||||||
case .english: return "🇬🇧"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var language: AppLanguage
|
|
||||||
|
|
||||||
// MARK: - Currency
|
|
||||||
var defaultCurrency: Currency
|
|
||||||
|
|
||||||
// MARK: - Notifications
|
|
||||||
struct NotificationSettings: Codable {
|
|
||||||
var isEnabled: Bool
|
|
||||||
var dailyReminder: Bool
|
|
||||||
var dailyReminderTime: Date
|
|
||||||
var weeklyReport: Bool
|
|
||||||
var transactionAlerts: Bool
|
|
||||||
}
|
|
||||||
var notifications: NotificationSettings
|
|
||||||
|
|
||||||
// MARK: - Privacy
|
|
||||||
struct PrivacySettings: Codable {
|
|
||||||
var hideBalances: Bool
|
|
||||||
var useBiometrics: Bool
|
|
||||||
var requirePasscodeOnLaunch: Bool
|
|
||||||
}
|
|
||||||
var privacy: PrivacySettings
|
|
||||||
|
|
||||||
// MARK: - Display
|
|
||||||
struct DisplaySettings: Codable {
|
|
||||||
var showCents: Bool
|
|
||||||
var groupTransactionsByDay: Bool
|
|
||||||
var showRunningBalance: Bool
|
|
||||||
}
|
|
||||||
var display: DisplaySettings
|
|
||||||
|
|
||||||
// MARK: - App Info
|
|
||||||
var currentVersion: String
|
|
||||||
var lastUpdateCheck: Date
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
static func defaultSettings() -> AppSettings {
|
|
||||||
AppSettings(
|
|
||||||
isAuthenticated: false,
|
|
||||||
userEmail: nil,
|
|
||||||
theme: .system,
|
|
||||||
language: .english,
|
|
||||||
defaultCurrency: Currency.current(),
|
|
||||||
notifications: NotificationSettings(
|
|
||||||
isEnabled: true,
|
|
||||||
dailyReminder: true,
|
|
||||||
dailyReminderTime: Calendar.current.date(from: DateComponents(hour: 20)) ?? Date(),
|
|
||||||
weeklyReport: true,
|
|
||||||
transactionAlerts: true
|
|
||||||
),
|
|
||||||
privacy: PrivacySettings(
|
|
||||||
hideBalances: false,
|
|
||||||
useBiometrics: true,
|
|
||||||
requirePasscodeOnLaunch: false
|
|
||||||
),
|
|
||||||
display: DisplaySettings(
|
|
||||||
showCents: true,
|
|
||||||
groupTransactionsByDay: true,
|
|
||||||
showRunningBalance: true
|
|
||||||
),
|
|
||||||
currentVersion: Bundle.main.appVersion,
|
|
||||||
lastUpdateCheck: Date()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Bundle Extension
|
|
||||||
extension Bundle {
|
|
||||||
var appVersion: String {
|
|
||||||
return infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct Currency: Hashable, Identifiable, Codable {
|
|
||||||
// MARK: - Properties
|
|
||||||
let code: String // ISO 4217 код
|
|
||||||
let numericCode: String // ISO 4217 числовой код
|
|
||||||
let name: String // Полное название
|
|
||||||
let symbol: String // Символ валюты
|
|
||||||
let fractionDigits: Int // Количество знаков после запятой
|
|
||||||
let minorUnit: Int // Минимальная единица (например, 100 для доллара - центы)
|
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
|
||||||
var id: String { code }
|
|
||||||
|
|
||||||
// MARK: - Localization
|
|
||||||
func localizedName(for locale: Locale = .current) -> String {
|
|
||||||
locale.localizedString(forCurrencyCode: code) ?? name
|
|
||||||
}
|
|
||||||
|
|
||||||
func localizedSymbol(for locale: Locale = .current) -> String {
|
|
||||||
locale.currencySymbol ?? symbol
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Formatting
|
|
||||||
extension Currency {
|
|
||||||
func format(_ amount: Decimal, style: NumberFormatter.Style = .currency, locale: Locale = .current) -> String {
|
|
||||||
let formatter = NumberFormatter()
|
|
||||||
formatter.numberStyle = style
|
|
||||||
formatter.currencyCode = code
|
|
||||||
formatter.locale = locale
|
|
||||||
formatter.minimumFractionDigits = fractionDigits
|
|
||||||
formatter.maximumFractionDigits = fractionDigits
|
|
||||||
|
|
||||||
return formatter.string(from: NSDecimalNumber(decimal: amount)) ?? "\(symbol)\(amount)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Currency Management
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если не удалось определить, возвращаем GBP как дефолтную
|
|
||||||
print("Using default GBP currency")
|
|
||||||
return Currency(
|
|
||||||
code: "GBP",
|
|
||||||
numericCode: "826",
|
|
||||||
name: "British Pound Sterling",
|
|
||||||
symbol: "£",
|
|
||||||
fractionDigits: 2,
|
|
||||||
minorUnit: 100
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Все доступные валюты
|
|
||||||
static var all: [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 }),
|
|
||||||
let name = locale.localizedString(forCurrencyCode: currency.identifier)
|
|
||||||
else { return nil }
|
|
||||||
|
|
||||||
let symbol = locale.currencySymbol ?? currency.identifier
|
|
||||||
|
|
||||||
return Currency(
|
|
||||||
code: currency.identifier,
|
|
||||||
numericCode: String(currency.identifier),
|
|
||||||
name: name,
|
|
||||||
symbol: symbol,
|
|
||||||
fractionDigits: Currency.getFractionDigits(for: currency.identifier),
|
|
||||||
minorUnit: Currency.getMinorUnit(for: currency.identifier)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.sorted { $0.code < $1.code }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func getFractionDigits(for currencyCode: String) -> Int {
|
|
||||||
switch currencyCode {
|
|
||||||
case "JPY", "KRW", "VND": return 0
|
|
||||||
case "BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND": return 3
|
|
||||||
default: return 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func getMinorUnit(for currencyCode: String) -> Int {
|
|
||||||
switch currencyCode {
|
|
||||||
case "JPY", "KRW", "VND": return 1
|
|
||||||
case "BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND": return 1000
|
|
||||||
default: return 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preview Data
|
|
||||||
#if DEBUG
|
|
||||||
extension Currency {
|
|
||||||
static var sampleData: [Currency] {
|
|
||||||
[
|
|
||||||
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: "Japanese Yen", symbol: "¥", fractionDigits: 0, minorUnit: 1)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
static var preview: Currency {
|
|
||||||
// GBP как дефолтная валюта для превью
|
|
||||||
sampleData[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class AccountViewModel: ObservableObject {
|
|
||||||
@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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
var archivedAccounts: [Account] {
|
|
||||||
accounts.filter { $0.isArchived }
|
|
||||||
}
|
|
||||||
|
|
||||||
var accountsByType: [AccountType: [Account]] {
|
|
||||||
Dictionary(grouping: activeAccounts) { $0.type }
|
|
||||||
}
|
|
||||||
|
|
||||||
var accountsByCurrency: [Currency: [Account]] {
|
|
||||||
Dictionary(grouping: activeAccounts) { $0.currency }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 }
|
|
||||||
.reduce(0) { $0 + $1.balance }
|
|
||||||
}
|
|
||||||
|
|
||||||
func totalBalanceFormatted(currency: Currency) -> String {
|
|
||||||
currency.format(totalBalance(for: currency))
|
|
||||||
}
|
|
||||||
|
|
||||||
func totalDebt(for currency: Currency) -> Decimal {
|
|
||||||
activeAccounts
|
|
||||||
.filter { $0.currency == currency && $0.type.isDebt && !$0.excludeFromStatistics }
|
|
||||||
.reduce(0) { $0 + $1.balance }
|
|
||||||
}
|
|
||||||
|
|
||||||
func totalDebtFormatted(currency: Currency) -> String {
|
|
||||||
currency.format(totalDebt(for: currency))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preview Helper
|
|
||||||
#if DEBUG
|
|
||||||
extension AccountViewModel {
|
|
||||||
static var preview: AccountViewModel {
|
|
||||||
let viewModel = AccountViewModel()
|
|
||||||
viewModel.accounts = Account.sampleData
|
|
||||||
return viewModel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
//
|
|
||||||
// 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
|
|
||||||
|
|
@ -1,247 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
struct AccountListView: View {
|
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
|
||||||
@EnvironmentObject private var settingsViewModel: SettingsViewModel
|
|
||||||
@StateObject private var viewModel = AccountViewModel()
|
|
||||||
@State private var showingAddAccount = false
|
|
||||||
@State private var showingSettings = false
|
|
||||||
@State private var showArchived = false
|
|
||||||
@State private var animateChanges = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollView {
|
|
||||||
VStack(spacing: 24) {
|
|
||||||
if viewModel.activeAccounts.isEmpty {
|
|
||||||
emptyStateView
|
|
||||||
.transition(.opacity.combined(with: .scale))
|
|
||||||
} else {
|
|
||||||
totalBalanceCard
|
|
||||||
.transition(.opacity.combined(with: .scale))
|
|
||||||
|
|
||||||
VStack(spacing: 24) {
|
|
||||||
ForEach(AccountType.basicAccounts, id: \.self) { type in
|
|
||||||
accountGroup(for: type)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !viewModel.accounts(for: AccountType.investmentAccounts[0]).isEmpty {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
Text("INVESTMENTS")
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(Theme.secondaryText)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
|
|
||||||
ForEach(AccountType.investmentAccounts, id: \.self) { type in
|
|
||||||
if !viewModel.accounts(for: type).isEmpty {
|
|
||||||
accountGroup(for: type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(.spring(), value: animateChanges)
|
|
||||||
.padding(.top)
|
|
||||||
}
|
|
||||||
.background(Theme.background)
|
|
||||||
.navigationTitle("Accounts")
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
HStack(spacing: 16) {
|
|
||||||
Button(action: {
|
|
||||||
hapticFeedback()
|
|
||||||
showingAddAccount = true
|
|
||||||
}) {
|
|
||||||
Image(systemName: "plus.circle.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundColor(Theme.primary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
hapticFeedback()
|
|
||||||
showingSettings = true
|
|
||||||
}) {
|
|
||||||
Image(systemName: "gear")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundColor(Theme.primary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingAddAccount) {
|
|
||||||
AccountFormView()
|
|
||||||
.environment(\.managedObjectContext, viewContext)
|
|
||||||
.environmentObject(settingsViewModel)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingSettings) {
|
|
||||||
SettingsView()
|
|
||||||
.environmentObject(settingsViewModel)
|
|
||||||
}
|
|
||||||
.refreshable {
|
|
||||||
hapticFeedback()
|
|
||||||
await viewModel.loadAccounts()
|
|
||||||
withAnimation {
|
|
||||||
animateChanges.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
await viewModel.loadAccounts()
|
|
||||||
animateChanges.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func accountGroup(for type: AccountType) -> some View {
|
|
||||||
let accounts = viewModel.accounts(for: type)
|
|
||||||
if accounts.isEmpty { return AnyView(EmptyView()) }
|
|
||||||
|
|
||||||
return AnyView(
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: type.icon)
|
|
||||||
.foregroundColor(Color(type.color))
|
|
||||||
Text(type.localizedName.uppercased())
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(Theme.secondaryText)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
ForEach(accounts) { account in
|
|
||||||
NavigationLink {
|
|
||||||
AccountDetailView(account: account)
|
|
||||||
.environment(\.managedObjectContext, viewContext)
|
|
||||||
.environmentObject(settingsViewModel)
|
|
||||||
} label: {
|
|
||||||
AccountRowView(account: account)
|
|
||||||
.environmentObject(settingsViewModel)
|
|
||||||
}
|
|
||||||
.buttonStyle(PlainButtonStyle())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var hasDebtAccounts: Bool {
|
|
||||||
AccountType.debtAccounts.contains { !viewModel.accounts(for: $0).isEmpty }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var emptyStateView: some View {
|
|
||||||
Text("No accounts available")
|
|
||||||
.font(.title)
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var totalBalanceCard: some View {
|
|
||||||
Text("Total Balance: $0.00")
|
|
||||||
.font(.title)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func hapticFeedback() {
|
|
||||||
let generator = UIImpactFeedbackGenerator(style: .medium)
|
|
||||||
generator.impactOccurred()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct AccountRowView: View {
|
|
||||||
let account: Account
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 16) {
|
|
||||||
// Icon
|
|
||||||
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(.system(.body, design: .rounded))
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(Theme.primaryText)
|
|
||||||
|
|
||||||
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(Theme.secondaryText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Balance
|
|
||||||
VStack(alignment: .trailing, spacing: 4) {
|
|
||||||
Text(account.formattedBalance)
|
|
||||||
.font(.system(.body, design: .rounded))
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(balanceColor)
|
|
||||||
|
|
||||||
if account.type == .credit {
|
|
||||||
Text("Available: \(account.currency.format(account.creditLimit ?? 0 - account.balance))")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(Theme.secondaryText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
|
||||||
private var balanceColor: Color {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
|
||||||
private func formatInterestRate(_ rate: Decimal) -> String {
|
|
||||||
let percentage = rate * 100
|
|
||||||
return String(format: "%.1f%%", NSDecimalNumber(decimal: percentage).doubleValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preview
|
|
||||||
#if DEBUG
|
|
||||||
struct AccountRowView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
Group {
|
|
||||||
AccountRowView(account: Account.sampleData[0])
|
|
||||||
AccountRowView(account: Account.sampleData[3])
|
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
}
|
|
||||||
.previewLayout(.sizeThatFits)
|
|
||||||
.padding()
|
|
||||||
.background(Theme.background)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
struct ContentView: View {
|
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
AccountListView()
|
|
||||||
.environment(\.managedObjectContext, viewContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ContentView()
|
|
||||||
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
//
|
|
||||||
// AuthViewModel.swift
|
|
||||||
// Coinly
|
|
||||||
//
|
|
||||||
// Created by Vadym Samoilenko on 03/03/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class AuthViewModel: ObservableObject {
|
|
||||||
@Published private(set) var isLoading = false
|
|
||||||
@Published var error: Error?
|
|
||||||
|
|
||||||
func signIn(email: String, password: String) async {
|
|
||||||
isLoading = true
|
|
||||||
error = nil
|
|
||||||
|
|
||||||
do {
|
|
||||||
// Здесь будет реализация аутентификации
|
|
||||||
// Пока просто симулируем задержку
|
|
||||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
|
||||||
// TODO: Implement actual authentication
|
|
||||||
} catch {
|
|
||||||
self.error = error
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func signUp(email: String, password: String) async {
|
|
||||||
isLoading = true
|
|
||||||
error = nil
|
|
||||||
|
|
||||||
do {
|
|
||||||
// Здесь будет реализация регистрации
|
|
||||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
|
||||||
// TODO: Implement actual registration
|
|
||||||
} catch {
|
|
||||||
self.error = error
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func signOut() {
|
|
||||||
// TODO: Implement sign out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class SettingsViewModel: ObservableObject {
|
|
||||||
// MARK: - Published Properties
|
|
||||||
@Published private(set) var settings: AppSettings
|
|
||||||
@Published private(set) var isLoading = false
|
|
||||||
@Published var error: Error?
|
|
||||||
|
|
||||||
// MARK: - Properties
|
|
||||||
private let repository: SettingsRepositoryProtocol
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
init(repository: SettingsRepositoryProtocol = SettingsRepository()) {
|
|
||||||
self.repository = repository
|
|
||||||
self.settings = repository.settings
|
|
||||||
|
|
||||||
setupBindings()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupBindings() {
|
|
||||||
repository.settingsPublisher
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] settings in
|
|
||||||
self?.settings = settings
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Theme Methods
|
|
||||||
func updateTheme(_ theme: AppSettings.AppTheme) {
|
|
||||||
var newSettings = settings
|
|
||||||
newSettings.theme = theme
|
|
||||||
saveSettings(newSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Language Methods
|
|
||||||
func updateLanguage(_ language: AppSettings.AppLanguage) {
|
|
||||||
var newSettings = settings
|
|
||||||
newSettings.language = language
|
|
||||||
saveSettings(newSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Currency Methods
|
|
||||||
func updateDefaultCurrency(_ currency: Currency) {
|
|
||||||
var newSettings = settings
|
|
||||||
newSettings.defaultCurrency = currency
|
|
||||||
saveSettings(newSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Privacy Settings
|
|
||||||
func updatePrivacySettings(
|
|
||||||
hideBalances: Bool? = nil,
|
|
||||||
useBiometrics: Bool? = nil,
|
|
||||||
requirePasscode: Bool? = nil
|
|
||||||
) {
|
|
||||||
var newSettings = settings
|
|
||||||
if let hideBalances = hideBalances {
|
|
||||||
newSettings.privacy.hideBalances = hideBalances
|
|
||||||
}
|
|
||||||
if let useBiometrics = useBiometrics {
|
|
||||||
newSettings.privacy.useBiometrics = useBiometrics
|
|
||||||
}
|
|
||||||
if let requirePasscode = requirePasscode {
|
|
||||||
newSettings.privacy.requirePasscodeOnLaunch = requirePasscode
|
|
||||||
}
|
|
||||||
saveSettings(newSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Notification Settings
|
|
||||||
func updateNotificationSettings(
|
|
||||||
isEnabled: Bool? = nil,
|
|
||||||
dailyReminder: Bool? = nil,
|
|
||||||
reminderTime: Date? = nil,
|
|
||||||
weeklyReport: Bool? = nil,
|
|
||||||
transactionAlerts: Bool? = nil
|
|
||||||
) {
|
|
||||||
var newSettings = settings
|
|
||||||
if let isEnabled = isEnabled {
|
|
||||||
newSettings.notifications.isEnabled = isEnabled
|
|
||||||
if !isEnabled {
|
|
||||||
// Disable all notifications when main switch is turned off
|
|
||||||
newSettings.notifications.dailyReminder = false
|
|
||||||
newSettings.notifications.weeklyReport = false
|
|
||||||
newSettings.notifications.transactionAlerts = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let dailyReminder = dailyReminder {
|
|
||||||
newSettings.notifications.dailyReminder = dailyReminder
|
|
||||||
}
|
|
||||||
if let reminderTime = reminderTime {
|
|
||||||
newSettings.notifications.dailyReminderTime = reminderTime
|
|
||||||
}
|
|
||||||
if let weeklyReport = weeklyReport {
|
|
||||||
newSettings.notifications.weeklyReport = weeklyReport
|
|
||||||
}
|
|
||||||
if let transactionAlerts = transactionAlerts {
|
|
||||||
newSettings.notifications.transactionAlerts = transactionAlerts
|
|
||||||
}
|
|
||||||
saveSettings(newSettings)
|
|
||||||
|
|
||||||
// Update system notification settings if needed
|
|
||||||
if isEnabled == true {
|
|
||||||
requestNotificationPermissions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Display Settings
|
|
||||||
func updateDisplaySettings(
|
|
||||||
showCents: Bool? = nil,
|
|
||||||
groupByDay: Bool? = nil,
|
|
||||||
showRunningBalance: Bool? = nil
|
|
||||||
) {
|
|
||||||
var newSettings = settings
|
|
||||||
if let showCents = showCents {
|
|
||||||
newSettings.display.showCents = showCents
|
|
||||||
}
|
|
||||||
if let groupByDay = groupByDay {
|
|
||||||
newSettings.display.groupTransactionsByDay = groupByDay
|
|
||||||
}
|
|
||||||
if let showRunningBalance = showRunningBalance {
|
|
||||||
newSettings.display.showRunningBalance = showRunningBalance
|
|
||||||
}
|
|
||||||
saveSettings(newSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Updates
|
|
||||||
func checkForUpdates() async {
|
|
||||||
isLoading = true
|
|
||||||
error = nil
|
|
||||||
|
|
||||||
do {
|
|
||||||
let hasUpdates = try await repository.checkForUpdates()
|
|
||||||
if hasUpdates {
|
|
||||||
// Handle update availability
|
|
||||||
NotificationCenter.default.post(name: .appUpdateAvailable, object: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
var newSettings = settings
|
|
||||||
newSettings.lastUpdateCheck = Date()
|
|
||||||
saveSettings(newSettings)
|
|
||||||
} catch {
|
|
||||||
self.error = error
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Reset
|
|
||||||
func resetSettings() {
|
|
||||||
do {
|
|
||||||
try repository.resetSettings()
|
|
||||||
} catch {
|
|
||||||
self.error = error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Error Handling
|
|
||||||
func clearError() {
|
|
||||||
error = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
|
||||||
private func saveSettings(_ newSettings: AppSettings) {
|
|
||||||
do {
|
|
||||||
try repository.updateSettings(newSettings)
|
|
||||||
} catch {
|
|
||||||
self.error = error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func requestNotificationPermissions() {
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await NotificationManager.shared.requestPermissions()
|
|
||||||
} catch {
|
|
||||||
self.error = error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Notification Names
|
|
||||||
extension Notification.Name {
|
|
||||||
static let appUpdateAvailable = Notification.Name("appUpdateAvailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preview Helper
|
|
||||||
#if DEBUG
|
|
||||||
extension SettingsViewModel {
|
|
||||||
static var preview: SettingsViewModel {
|
|
||||||
let viewModel = SettingsViewModel()
|
|
||||||
return viewModel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
@ -1,386 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
|
||||||
@StateObject private var viewModel = SettingsViewModel()
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
// Local state for settings
|
|
||||||
@State private var selectedTheme: AppSettings.AppTheme
|
|
||||||
@State private var selectedLanguage: AppSettings.AppLanguage
|
|
||||||
@State private var selectedCurrency: Currency
|
|
||||||
@State private var showingConfirmation = false
|
|
||||||
|
|
||||||
// Privacy settings
|
|
||||||
@State private var hideBalances: Bool
|
|
||||||
@State private var useBiometrics: Bool
|
|
||||||
@State private var requirePasscodeOnLaunch: Bool
|
|
||||||
|
|
||||||
// Notification settings
|
|
||||||
@State private var notificationsEnabled: Bool
|
|
||||||
@State private var dailyReminder: Bool
|
|
||||||
@State private var dailyReminderTime: Date
|
|
||||||
@State private var weeklyReport: Bool
|
|
||||||
@State private var transactionAlerts: Bool
|
|
||||||
|
|
||||||
// Display settings
|
|
||||||
@State private var showCents: Bool
|
|
||||||
@State private var groupTransactionsByDay: Bool
|
|
||||||
@State private var showRunningBalance: Bool
|
|
||||||
|
|
||||||
// Initialize state from viewModel
|
|
||||||
init() {
|
|
||||||
let settings = SettingsViewModel().settings
|
|
||||||
_selectedTheme = State(initialValue: settings.theme)
|
|
||||||
_selectedLanguage = State(initialValue: settings.language)
|
|
||||||
_selectedCurrency = State(initialValue: settings.defaultCurrency)
|
|
||||||
|
|
||||||
// Privacy
|
|
||||||
_hideBalances = State(initialValue: settings.privacy.hideBalances)
|
|
||||||
_useBiometrics = State(initialValue: settings.privacy.useBiometrics)
|
|
||||||
_requirePasscodeOnLaunch = State(initialValue: settings.privacy.requirePasscodeOnLaunch)
|
|
||||||
|
|
||||||
// Notifications
|
|
||||||
_notificationsEnabled = State(initialValue: settings.notifications.isEnabled)
|
|
||||||
_dailyReminder = State(initialValue: settings.notifications.dailyReminder)
|
|
||||||
_dailyReminderTime = State(initialValue: settings.notifications.dailyReminderTime)
|
|
||||||
_weeklyReport = State(initialValue: settings.notifications.weeklyReport)
|
|
||||||
_transactionAlerts = State(initialValue: settings.notifications.transactionAlerts)
|
|
||||||
|
|
||||||
// Display
|
|
||||||
_showCents = State(initialValue: settings.display.showCents)
|
|
||||||
_groupTransactionsByDay = State(initialValue: settings.display.groupTransactionsByDay)
|
|
||||||
_showRunningBalance = State(initialValue: settings.display.showRunningBalance)
|
|
||||||
}
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
List {
|
|
||||||
// Account Section
|
|
||||||
accountSection
|
|
||||||
|
|
||||||
// Appearance Section
|
|
||||||
appearanceSection
|
|
||||||
|
|
||||||
// Currency Section
|
|
||||||
currencySection
|
|
||||||
|
|
||||||
// Privacy Section
|
|
||||||
privacySection
|
|
||||||
|
|
||||||
// Notifications Section
|
|
||||||
notificationsSection
|
|
||||||
|
|
||||||
// Display Section
|
|
||||||
displaySection
|
|
||||||
|
|
||||||
// App Info Section
|
|
||||||
appInfoSection
|
|
||||||
|
|
||||||
// Reset Section
|
|
||||||
resetSection
|
|
||||||
}
|
|
||||||
.navigationTitle("Settings")
|
|
||||||
.alert("Error", isPresented: Binding(
|
|
||||||
get: { viewModel.error != nil },
|
|
||||||
set: { _ in viewModel.clearError() }
|
|
||||||
)) {
|
|
||||||
Button("OK") {
|
|
||||||
viewModel.clearError()
|
|
||||||
}
|
|
||||||
} message: {
|
|
||||||
if let error = viewModel.error {
|
|
||||||
Text(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.confirmationDialog(
|
|
||||||
"Reset Settings",
|
|
||||||
isPresented: $showingConfirmation,
|
|
||||||
titleVisibility: .visible
|
|
||||||
) {
|
|
||||||
Button("Reset", role: .destructive) {
|
|
||||||
resetSettings()
|
|
||||||
}
|
|
||||||
Button("Cancel", role: .cancel) { }
|
|
||||||
} message: {
|
|
||||||
Text("This will reset all settings to their default values. This action cannot be undone.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Sections
|
|
||||||
private var accountSection: some View {
|
|
||||||
Section {
|
|
||||||
if viewModel.settings.isAuthenticated {
|
|
||||||
userProfileSection
|
|
||||||
} else {
|
|
||||||
authenticationSection
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("ACCOUNT")
|
|
||||||
} footer: {
|
|
||||||
if !viewModel.settings.isAuthenticated {
|
|
||||||
Text("Sign in to sync your data across devices")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private var appearanceSection: some View {
|
|
||||||
Section {
|
|
||||||
ForEach(AppSettings.AppTheme.allCases, id: \.self) { theme in
|
|
||||||
ThemeRowView(
|
|
||||||
theme: theme,
|
|
||||||
isSelected: selectedTheme == theme
|
|
||||||
)
|
|
||||||
.onTapGesture {
|
|
||||||
withAnimation {
|
|
||||||
selectedTheme = theme
|
|
||||||
viewModel.updateTheme(theme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("APPEARANCE")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var currencySection: some View {
|
|
||||||
Section {
|
|
||||||
Picker("Default Currency", selection: $selectedCurrency) {
|
|
||||||
ForEach(Currency.all) { currency in
|
|
||||||
HStack {
|
|
||||||
Text(currency.code)
|
|
||||||
.font(.system(.body, design: .monospaced))
|
|
||||||
Text("-")
|
|
||||||
Text(currency.localizedName())
|
|
||||||
}
|
|
||||||
.tag(currency)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: selectedCurrency) { oldValue, newValue in
|
|
||||||
viewModel.updateDefaultCurrency(newValue)
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("CURRENCY")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var privacySection: some View {
|
|
||||||
Section {
|
|
||||||
Toggle("Hide Balances", isOn: $hideBalances)
|
|
||||||
.onChange(of: hideBalances) { oldValue, newValue in
|
|
||||||
viewModel.updatePrivacySettings(hideBalances: newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("Use Face ID", isOn: $useBiometrics)
|
|
||||||
.onChange(of: useBiometrics) { oldValue, newValue in
|
|
||||||
viewModel.updatePrivacySettings(useBiometrics: newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("Require Passcode on Launch", isOn: $requirePasscodeOnLaunch)
|
|
||||||
.onChange(of: requirePasscodeOnLaunch) { oldValue, newValue in
|
|
||||||
viewModel.updatePrivacySettings(requirePasscode: newValue)
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("PRIVACY & SECURITY")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private var notificationsSection: some View {
|
|
||||||
Section {
|
|
||||||
Toggle("Enable Notifications", isOn: $notificationsEnabled)
|
|
||||||
.onChange(of: notificationsEnabled) { oldValue, newValue in
|
|
||||||
viewModel.updateNotificationSettings(isEnabled: newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
if notificationsEnabled {
|
|
||||||
Toggle("Daily Balance Reminder", isOn: $dailyReminder)
|
|
||||||
.onChange(of: dailyReminder) { oldValue, newValue in
|
|
||||||
viewModel.updateNotificationSettings(dailyReminder: newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
if dailyReminder {
|
|
||||||
DatePicker("Reminder Time", selection: $dailyReminderTime, displayedComponents: .hourAndMinute)
|
|
||||||
.onChange(of: dailyReminderTime) { oldValue, newValue in
|
|
||||||
viewModel.updateNotificationSettings(reminderTime: newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("Weekly Report", isOn: $weeklyReport)
|
|
||||||
.onChange(of: weeklyReport) { oldValue, newValue in
|
|
||||||
viewModel.updateNotificationSettings(weeklyReport: newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("Transaction Alerts", isOn: $transactionAlerts)
|
|
||||||
.onChange(of: transactionAlerts) { oldValue, newValue in
|
|
||||||
viewModel.updateNotificationSettings(transactionAlerts: newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("NOTIFICATIONS")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var displaySection: some View {
|
|
||||||
Section {
|
|
||||||
Toggle("Show Cents", isOn: $showCents)
|
|
||||||
.onChange(of: showCents) { oldValue, newValue in
|
|
||||||
viewModel.updateDisplaySettings(showCents: newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("Group Transactions by Day", isOn: $groupTransactionsByDay)
|
|
||||||
.onChange(of: groupTransactionsByDay) { oldValue, newValue in
|
|
||||||
viewModel.updateDisplaySettings(groupByDay: newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("Show Running Balance", isOn: $showRunningBalance)
|
|
||||||
.onChange(of: showRunningBalance) { oldValue, newValue in
|
|
||||||
viewModel.updateDisplaySettings(showRunningBalance: newValue)
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("DISPLAY")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private var appInfoSection: some View {
|
|
||||||
Section {
|
|
||||||
HStack {
|
|
||||||
Text("Version")
|
|
||||||
Spacer()
|
|
||||||
Text(viewModel.settings.currentVersion)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: checkForUpdates) {
|
|
||||||
HStack {
|
|
||||||
Text("Check for Updates")
|
|
||||||
if viewModel.isLoading {
|
|
||||||
Spacer()
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(viewModel.isLoading)
|
|
||||||
} header: {
|
|
||||||
Text("ABOUT")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var resetSection: some View {
|
|
||||||
Section {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
showingConfirmation = true
|
|
||||||
} label: {
|
|
||||||
Text("Reset All Settings")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Supporting Views
|
|
||||||
private struct ThemeRowView: View {
|
|
||||||
let theme: AppSettings.AppTheme
|
|
||||||
let isSelected: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: theme.icon)
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
Text(theme.rawValue)
|
|
||||||
Spacer()
|
|
||||||
if isSelected {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ViewBuilder
|
|
||||||
private var userProfileSection: some View {
|
|
||||||
if let email = viewModel.settings.userEmail {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(email)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Text("Signed in")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(role: .destructive) {
|
|
||||||
// Handle sign out
|
|
||||||
} label: {
|
|
||||||
Text("Sign Out")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var authenticationSection: some View {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
NavigationLink {
|
|
||||||
SignInView()
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "person.fill")
|
|
||||||
Text("Sign In")
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(Color.accentColor)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(10)
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink {
|
|
||||||
SignUpView()
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "person.badge.plus")
|
|
||||||
Text("Create Account")
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(Color.secondary.opacity(0.1))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.cornerRadius(10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Actions
|
|
||||||
private func checkForUpdates() {
|
|
||||||
Task {
|
|
||||||
await viewModel.checkForUpdates()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resetSettings() {
|
|
||||||
viewModel.resetSettings()
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
selectedTheme = viewModel.settings.theme
|
|
||||||
selectedLanguage = viewModel.settings.language
|
|
||||||
selectedCurrency = viewModel.settings.defaultCurrency
|
|
||||||
|
|
||||||
// Privacy
|
|
||||||
hideBalances = viewModel.settings.privacy.hideBalances
|
|
||||||
useBiometrics = viewModel.settings.privacy.useBiometrics
|
|
||||||
requirePasscodeOnLaunch = viewModel.settings.privacy.requirePasscodeOnLaunch
|
|
||||||
|
|
||||||
// Notifications
|
|
||||||
notificationsEnabled = viewModel.settings.notifications.isEnabled
|
|
||||||
dailyReminder = viewModel.settings.notifications.dailyReminder
|
|
||||||
dailyReminderTime = viewModel.settings.notifications.dailyReminderTime
|
|
||||||
weeklyReport = viewModel.settings.notifications.weeklyReport
|
|
||||||
transactionAlerts = viewModel.settings.notifications.transactionAlerts
|
|
||||||
|
|
||||||
// Display
|
|
||||||
showCents = viewModel.settings.display.showCents
|
|
||||||
groupTransactionsByDay = viewModel.settings.display.groupTransactionsByDay
|
|
||||||
showRunningBalance = viewModel.settings.display.showRunningBalance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preview
|
|
||||||
struct SettingsView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
SettingsView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct SignInView: View {
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
@EnvironmentObject private var settingsViewModel: SettingsViewModel
|
|
||||||
@StateObject private var viewModel = AuthViewModel()
|
|
||||||
|
|
||||||
@State private var email = ""
|
|
||||||
@State private var password = ""
|
|
||||||
@State private var showingError = false
|
|
||||||
@State private var isSecured = true
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section {
|
|
||||||
TextField("Email", text: $email)
|
|
||||||
.textContentType(.emailAddress)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.keyboardType(.emailAddress)
|
|
||||||
.autocorrectionDisabled(true)
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
if isSecured {
|
|
||||||
SecureField("Password", text: $password)
|
|
||||||
.textContentType(.password)
|
|
||||||
} else {
|
|
||||||
TextField("Password", text: $password)
|
|
||||||
.textContentType(.password)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: { isSecured.toggle() }) {
|
|
||||||
Image(systemName: isSecured ? "eye.slash" : "eye")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("CREDENTIALS")
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Button(action: signIn) {
|
|
||||||
HStack {
|
|
||||||
Text("Sign In")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
if viewModel.isLoading {
|
|
||||||
Spacer()
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(viewModel.isLoading || !isValid)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Button("Forgot Password?") {
|
|
||||||
// Handle forgot password
|
|
||||||
}
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Sign In")
|
|
||||||
.navigationBarTitleDisplayMode(.large)
|
|
||||||
.alert("Error", isPresented: $showingError) {
|
|
||||||
Button("OK") { }
|
|
||||||
} message: {
|
|
||||||
if let error = viewModel.error {
|
|
||||||
Text(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isValid: Bool {
|
|
||||||
!email.isEmpty && !password.isEmpty && email.contains("@")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func signIn() {
|
|
||||||
Task {
|
|
||||||
await viewModel.signIn(email: email, password: password)
|
|
||||||
if viewModel.error != nil {
|
|
||||||
showingError = true
|
|
||||||
} else {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SignInView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
NavigationView {
|
|
||||||
SignInView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct SignUpView: View {
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
@EnvironmentObject private var settingsViewModel: SettingsViewModel
|
|
||||||
@StateObject private var viewModel = AuthViewModel()
|
|
||||||
|
|
||||||
@State private var email = ""
|
|
||||||
@State private var password = ""
|
|
||||||
@State private var confirmPassword = ""
|
|
||||||
@State private var showingError = false
|
|
||||||
@State private var agreedToTerms = false
|
|
||||||
@State private var isPasswordSecured = true
|
|
||||||
@State private var isConfirmPasswordSecured = true
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section {
|
|
||||||
TextField("Email", text: $email)
|
|
||||||
.textContentType(.emailAddress)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.keyboardType(.emailAddress)
|
|
||||||
.autocorrectionDisabled(true)
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
if isPasswordSecured {
|
|
||||||
SecureField("Password", text: $password)
|
|
||||||
.textContentType(.newPassword)
|
|
||||||
} else {
|
|
||||||
TextField("Password", text: $password)
|
|
||||||
.textContentType(.newPassword)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: { isPasswordSecured.toggle() }) {
|
|
||||||
Image(systemName: isPasswordSecured ? "eye.slash" : "eye")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
if isConfirmPasswordSecured {
|
|
||||||
SecureField("Confirm Password", text: $confirmPassword)
|
|
||||||
.textContentType(.newPassword)
|
|
||||||
} else {
|
|
||||||
TextField("Confirm Password", text: $confirmPassword)
|
|
||||||
.textContentType(.newPassword)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: { isConfirmPasswordSecured.toggle() }) {
|
|
||||||
Image(systemName: isConfirmPasswordSecured ? "eye.slash" : "eye")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("ACCOUNT DETAILS")
|
|
||||||
} footer: {
|
|
||||||
if !password.isEmpty {
|
|
||||||
PasswordStrengthView(password: password)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Toggle("I agree to Terms and Conditions", isOn: $agreedToTerms)
|
|
||||||
|
|
||||||
Button(action: signUp) {
|
|
||||||
HStack {
|
|
||||||
Text("Create Account")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
if viewModel.isLoading {
|
|
||||||
Spacer()
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(viewModel.isLoading || !isValid)
|
|
||||||
} footer: {
|
|
||||||
Text("By creating an account, you agree to our Terms of Service and Privacy Policy")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Create Account")
|
|
||||||
.navigationBarTitleDisplayMode(.large)
|
|
||||||
.alert("Error", isPresented: $showingError) {
|
|
||||||
Button("OK") { }
|
|
||||||
} message: {
|
|
||||||
if let error = viewModel.error {
|
|
||||||
Text(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isValid: Bool {
|
|
||||||
!email.isEmpty &&
|
|
||||||
!password.isEmpty &&
|
|
||||||
password == confirmPassword &&
|
|
||||||
email.contains("@") &&
|
|
||||||
password.count >= 8 &&
|
|
||||||
agreedToTerms
|
|
||||||
}
|
|
||||||
|
|
||||||
private func signUp() {
|
|
||||||
Task {
|
|
||||||
await viewModel.signUp(email: email, password: password)
|
|
||||||
if viewModel.error != nil {
|
|
||||||
showingError = true
|
|
||||||
} else {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PasswordStrengthView: View {
|
|
||||||
let password: String
|
|
||||||
|
|
||||||
private var strength: Int {
|
|
||||||
var strength = 0
|
|
||||||
if password.count >= 8 { strength += 1 }
|
|
||||||
if password.contains(where: { $0.isNumber }) { strength += 1 }
|
|
||||||
if password.contains(where: { $0.isUppercase }) { strength += 1 }
|
|
||||||
if password.contains(where: { $0.isLowercase }) { strength += 1 }
|
|
||||||
if password.contains(where: { !$0.isLetter && !$0.isNumber }) { strength += 1 }
|
|
||||||
return strength
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text("Password Strength: \(strengthText)")
|
|
||||||
.foregroundColor(strengthColor)
|
|
||||||
|
|
||||||
HStack(spacing: 2) {
|
|
||||||
ForEach(0..<5) { index in
|
|
||||||
Rectangle()
|
|
||||||
.fill(index < strength ? strengthColor : Color.gray.opacity(0.3))
|
|
||||||
.frame(height: 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var strengthText: String {
|
|
||||||
switch strength {
|
|
||||||
case 0...1: return "Weak"
|
|
||||||
case 2...3: return "Medium"
|
|
||||||
case 4: return "Strong"
|
|
||||||
case 5: return "Very Strong"
|
|
||||||
default: return "Weak"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var strengthColor: Color {
|
|
||||||
switch strength {
|
|
||||||
case 0...1: return .red
|
|
||||||
case 2...3: return .orange
|
|
||||||
case 4: return .yellow
|
|
||||||
case 5: return .green
|
|
||||||
default: return .red
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SignUpView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
NavigationView {
|
|
||||||
SignUpView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>UIBackgroundModes</key>
|
|
||||||
<array>
|
|
||||||
<string>remote-notification</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
//
|
|
||||||
// CoinlyTests.swift
|
|
||||||
// CoinlyTests
|
|
||||||
//
|
|
||||||
// Created by Vadym Samoilenko on 03/03/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
@testable import Coinly
|
|
||||||
|
|
||||||
final class CoinlyTests: XCTestCase {
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
|
||||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
|
||||||
}
|
|
||||||
|
|
||||||
func testExample() throws {
|
|
||||||
// This is an example of a functional test case.
|
|
||||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
|
||||||
// Any test you write for XCTest can be annotated as throws and async.
|
|
||||||
// Mark your test throws to produce an unexpected failure when your test encounters an uncaught error.
|
|
||||||
// Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards.
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPerformanceExample() throws {
|
|
||||||
// This is an example of a performance test case.
|
|
||||||
self.measure {
|
|
||||||
// Put the code you want to measure the time of here.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
//
|
|
||||||
// CoinlyUITests.swift
|
|
||||||
// CoinlyUITests
|
|
||||||
//
|
|
||||||
// Created by Vadym Samoilenko on 03/03/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
|
|
||||||
final class CoinlyUITests: XCTestCase {
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
|
||||||
|
|
||||||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
|
||||||
continueAfterFailure = false
|
|
||||||
|
|
||||||
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
|
||||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testExample() throws {
|
|
||||||
// UI tests must launch the application that they test.
|
|
||||||
let app = XCUIApplication()
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testLaunchPerformance() throws {
|
|
||||||
if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
|
|
||||||
// This measures how long it takes to launch your application.
|
|
||||||
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
|
||||||
XCUIApplication().launch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
//
|
|
||||||
// CoinlyUITestsLaunchTests.swift
|
|
||||||
// CoinlyUITests
|
|
||||||
//
|
|
||||||
// Created by Vadym Samoilenko on 03/03/2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
|
|
||||||
final class CoinlyUITestsLaunchTests: XCTestCase {
|
|
||||||
|
|
||||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
continueAfterFailure = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func testLaunch() throws {
|
|
||||||
let app = XCUIApplication()
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Insert steps here to perform after app launch but before taking a screenshot,
|
|
||||||
// such as logging into a test account or navigating somewhere in the app
|
|
||||||
|
|
||||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
|
||||||
attachment.name = "Launch Screen"
|
|
||||||
attachment.lifetime = .keepAlways
|
|
||||||
add(attachment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
110
Coinly/Core/Style/AppStyle.swift
Normal file
110
Coinly/Core/Style/AppStyle.swift
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
//
|
||||||
|
// AppStyle.swift
|
||||||
|
// Coinly
|
||||||
|
//
|
||||||
|
// Created by Vadym Samoilenko on 02/03/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
// AppStyle.swift
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum AppStyle {
|
||||||
|
// Colors
|
||||||
|
static let backgroundPrimary = Color(uiColor: .systemBackground)
|
||||||
|
static let backgroundSecondary = Color(uiColor: .secondarySystemBackground)
|
||||||
|
static let backgroundTertiary = Color(uiColor: .tertiarySystemBackground)
|
||||||
|
|
||||||
|
static let labelPrimary = Color(uiColor: .label)
|
||||||
|
static let labelSecondary = Color(uiColor: .secondaryLabel)
|
||||||
|
static let labelTertiary = Color(uiColor: .tertiaryLabel)
|
||||||
|
|
||||||
|
// Corner Radius
|
||||||
|
static let cornerRadiusSmall: CGFloat = 8
|
||||||
|
static let cornerRadiusMedium: CGFloat = 12
|
||||||
|
static let cornerRadiusLarge: CGFloat = 16
|
||||||
|
|
||||||
|
// Padding
|
||||||
|
static let paddingSmall: CGFloat = 8
|
||||||
|
static let paddingMedium: CGFloat = 16
|
||||||
|
static let paddingLarge: CGFloat = 24
|
||||||
|
|
||||||
|
// Font Sizes
|
||||||
|
static let fontTitle = Font.system(size: 34, weight: .bold)
|
||||||
|
static let fontTitle2 = Font.system(size: 28, weight: .semibold)
|
||||||
|
static let fontTitle3 = Font.system(size: 22, weight: .semibold)
|
||||||
|
static let fontHeadline = Font.system(size: 17, weight: .semibold)
|
||||||
|
static let fontBody = Font.system(size: 17, weight: .regular)
|
||||||
|
static let fontCallout = Font.system(size: 16, weight: .regular)
|
||||||
|
static let fontSubheadline = Font.system(size: 15, weight: .regular)
|
||||||
|
static let fontFootnote = Font.system(size: 13, weight: .regular)
|
||||||
|
static let fontCaption = Font.system(size: 12, weight: .regular)
|
||||||
|
|
||||||
|
// Shadows
|
||||||
|
struct ShadowModifier: ViewModifier {
|
||||||
|
let radius: CGFloat
|
||||||
|
let y: CGFloat
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.shadow(
|
||||||
|
color: Color.black.opacity(0.1),
|
||||||
|
radius: radius,
|
||||||
|
x: 0,
|
||||||
|
y: y
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom ViewModifiers
|
||||||
|
struct CardStyle: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.background(backgroundSecondary)
|
||||||
|
.cornerRadius(cornerRadiusLarge)
|
||||||
|
.modifier(ShadowModifier(radius: 10, y: 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PrimaryButtonStyle: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.font(fontHeadline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.vertical, paddingMedium)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color.accentColor)
|
||||||
|
.cornerRadius(cornerRadiusMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SecondaryButtonStyle: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.font(fontHeadline)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.padding(.vertical, paddingMedium)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(backgroundSecondary)
|
||||||
|
.cornerRadius(cornerRadiusMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension для удобного использования стилей
|
||||||
|
extension View {
|
||||||
|
func cardStyle() -> some View {
|
||||||
|
modifier(AppStyle.CardStyle())
|
||||||
|
}
|
||||||
|
|
||||||
|
func primaryButtonStyle() -> some View {
|
||||||
|
modifier(AppStyle.PrimaryButtonStyle())
|
||||||
|
}
|
||||||
|
|
||||||
|
func secondaryButtonStyle() -> some View {
|
||||||
|
modifier(AppStyle.SecondaryButtonStyle())
|
||||||
|
}
|
||||||
|
|
||||||
|
func appShadow(radius: CGFloat = 10, y: CGFloat = 2) -> some View {
|
||||||
|
modifier(AppStyle.ShadowModifier(radius: radius, y: y))
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Coinly/Core/Utils/AppCurrencyFormatter.swift
Normal file
20
Coinly/Core/Utils/AppCurrencyFormatter.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct AppCurrencyFormatter {
|
||||||
|
static func format(_ amount: Double, currency: AppSettings.Currency = AppSettings.shared.selectedCurrency) -> String {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = .currency
|
||||||
|
formatter.currencyCode = currency.rawValue
|
||||||
|
formatter.minimumFractionDigits = 2
|
||||||
|
formatter.maximumFractionDigits = 2
|
||||||
|
|
||||||
|
return formatter.string(from: NSNumber(value: amount)) ?? "\(currency.symbol)\(amount)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем расширение для Double для удобного форматирования
|
||||||
|
extension Double {
|
||||||
|
func formatAsCurrency(currency: AppSettings.Currency = AppSettings.shared.selectedCurrency) -> String {
|
||||||
|
AppCurrencyFormatter.format(self, currency: currency)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
Coinly/Extensions/Color+Extensions.swift
Normal file
33
Coinly/Extensions/Color+Extensions.swift
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
//
|
||||||
|
// Color+Extensions.swift
|
||||||
|
// Coinly
|
||||||
|
//
|
||||||
|
// Created by Vadym Samoilenko on 02/03/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
init(_ systemColor: String) {
|
||||||
|
switch systemColor {
|
||||||
|
case "systemRed":
|
||||||
|
self = Color(uiColor: .systemRed)
|
||||||
|
case "systemBlue":
|
||||||
|
self = Color(uiColor: .systemBlue)
|
||||||
|
case "systemGreen":
|
||||||
|
self = Color(uiColor: .systemGreen)
|
||||||
|
case "systemYellow":
|
||||||
|
self = Color(uiColor: .systemYellow)
|
||||||
|
case "systemPurple":
|
||||||
|
self = Color(uiColor: .systemPurple)
|
||||||
|
case "systemOrange":
|
||||||
|
self = Color(uiColor: .systemOrange)
|
||||||
|
case "systemPink":
|
||||||
|
self = Color(uiColor: .systemPink)
|
||||||
|
case "systemIndigo":
|
||||||
|
self = Color(uiColor: .systemIndigo)
|
||||||
|
default:
|
||||||
|
self = Color(uiColor: .systemGray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
Coinly/Features/Accounts/AccountCardView.swift
Normal file
45
Coinly/Features/Accounts/AccountCardView.swift
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AccountCardView: View {
|
||||||
|
@EnvironmentObject private var accountsStore: AccountsStore
|
||||||
|
let account: AccountModel
|
||||||
|
let isSelected: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: account.icon)
|
||||||
|
.font(.title2)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if account.id == accountsStore.defaultAccountId {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.foregroundColor(.yellow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(account.name)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text(account.type.rawValue)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(account.balance.formatted(.currency(code: account.currency.rawValue)))
|
||||||
|
.font(.title3.bold())
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(width: 160)
|
||||||
|
.background(isSelected ? Color.accentColor.opacity(0.2) : Color(uiColor: .secondarySystemBackground))
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
AccountCardView(account: AccountModel.previewData, isSelected: false)
|
||||||
|
.environmentObject(AccountsStore.shared)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
88
Coinly/Features/Accounts/AccountDetailView.swift
Normal file
88
Coinly/Features/Accounts/AccountDetailView.swift
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AccountDetailView: View {
|
||||||
|
@EnvironmentObject private var accountsStore: AccountsStore
|
||||||
|
@EnvironmentObject private var transactionsStore: TransactionsStore
|
||||||
|
let account: AccountModel
|
||||||
|
|
||||||
|
@State private var showingEditSheet = false
|
||||||
|
@State private var showingAddTransaction = false
|
||||||
|
|
||||||
|
private var transactions: [TransactionModel] {
|
||||||
|
transactionsStore.filterTransactions(
|
||||||
|
filter: TransactionFilter(accountId: account.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAddTransaction(_ transaction: TransactionModel) {
|
||||||
|
transactionsStore.addTransaction(transaction)
|
||||||
|
if let targetAccount = accountsStore.getAccount(withId: transaction.accountId ?? "") {
|
||||||
|
let amount = transaction.type == .income ? transaction.amount : -transaction.amount
|
||||||
|
let newBalance = targetAccount.balance + amount
|
||||||
|
accountsStore.updateAccount(targetAccount.with(balance: newBalance))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
AccountCardView(
|
||||||
|
account: account,
|
||||||
|
isSelected: false
|
||||||
|
)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
|
||||||
|
if transactions.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Transactions",
|
||||||
|
systemImage: "tray.fill",
|
||||||
|
description: Text("Add your first transaction to start tracking your finances")
|
||||||
|
)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
} else {
|
||||||
|
ForEach(transactions) { transaction in
|
||||||
|
TransactionRowView(transaction: transaction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(account.name)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showingEditSheet = true
|
||||||
|
} label: {
|
||||||
|
Text("Edit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showingAddTransaction = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingEditSheet) {
|
||||||
|
NavigationView {
|
||||||
|
EditAccountView(account: account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingAddTransaction) {
|
||||||
|
NavigationView {
|
||||||
|
AddTransactionView { transaction in
|
||||||
|
handleAddTransaction(transaction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
AccountDetailView(account: AccountModel.previewData)
|
||||||
|
.environmentObject(AccountsStore.shared)
|
||||||
|
.environmentObject(TransactionsStore.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
54
Coinly/Features/Accounts/AccountPickerView.swift
Normal file
54
Coinly/Features/Accounts/AccountPickerView.swift
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
//
|
||||||
|
// AccountPickerView.swift
|
||||||
|
// Coinly
|
||||||
|
//
|
||||||
|
// Created by Vadym Samoilenko on 03/03/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AccountPickerView: View {
|
||||||
|
@EnvironmentObject private var accountsStore: AccountsStore
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var searchText = ""
|
||||||
|
|
||||||
|
let onSelect: (AccountModel) -> Void
|
||||||
|
|
||||||
|
private var filteredAccounts: [AccountModel] {
|
||||||
|
if searchText.isEmpty {
|
||||||
|
return accountsStore.accounts
|
||||||
|
}
|
||||||
|
return accountsStore.accounts.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(filteredAccounts) { account in
|
||||||
|
Button {
|
||||||
|
onSelect(account)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
AccountRowView(account: account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Select Account")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.searchable(text: $searchText, prompt: "Search accounts")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
AccountPickerView { _ in }
|
||||||
|
.environmentObject(AccountsStore.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Coinly/Features/Accounts/AccountRowView.swift
Normal file
41
Coinly/Features/Accounts/AccountRowView.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AccountRowView: View {
|
||||||
|
let account: AccountModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: account.icon)
|
||||||
|
.foregroundColor(account.type.color)
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(account.name)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text(account.type.rawValue)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text(account.balance.formatted(.currency(code: account.currency.rawValue)))
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if account.isDefault {
|
||||||
|
Text("Default")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
AccountRowView(account: AccountModel.previewData)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
90
Coinly/Features/Accounts/AccountsListView.swift
Normal file
90
Coinly/Features/Accounts/AccountsListView.swift
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AccountsListView: View {
|
||||||
|
@EnvironmentObject private var accountsStore: AccountsStore
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
@State private var showingAddAccount = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if !accountsStore.accounts.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(accountsStore.accounts) { account in
|
||||||
|
NavigationLink(destination: AccountDetailView(account: account)) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: account.icon)
|
||||||
|
.font(.title2)
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(account.name)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if account.id == accountsStore.defaultAccountId {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.foregroundColor(.yellow)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(account.type.rawValue)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(account.balance.formatted(.currency(code: account.currency.rawValue)))
|
||||||
|
.font(.callout.bold())
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
Text("Total Balance")
|
||||||
|
Spacer()
|
||||||
|
Text(accountsStore.totalBalance.formatted(.currency(code: settings.selectedCurrency.rawValue)))
|
||||||
|
}
|
||||||
|
.textCase(nil)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Accounts")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showingAddAccount = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingAddAccount) {
|
||||||
|
NavigationView {
|
||||||
|
AddAccountView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if accountsStore.accounts.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Accounts",
|
||||||
|
systemImage: "creditcard",
|
||||||
|
description: Text("Add your first account to start tracking your finances")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
AccountsListView()
|
||||||
|
.environmentObject(AccountsStore.shared)
|
||||||
|
.environmentObject(AppSettings.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
Coinly/Features/Accounts/AccountsSummaryView.swift
Normal file
58
Coinly/Features/Accounts/AccountsSummaryView.swift
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// Path: Features/Accounts/AccountsSummaryView.swift
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AccountsSummaryView: View {
|
||||||
|
@EnvironmentObject private var accountsStore: AccountsStore
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("Total Balance")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text(accountsStore.totalBalance.formatted(.currency(code: settings.selectedCurrency.rawValue)))
|
||||||
|
.font(.largeTitle.bold())
|
||||||
|
|
||||||
|
if accountsStore.accounts.isEmpty {
|
||||||
|
Text("No accounts")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
ForEach(accountsStore.accounts) { account in
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text(account.name)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text(account.balance.formatted(.currency(code: account.currency.rawValue)))
|
||||||
|
.font(.callout.bold())
|
||||||
|
}
|
||||||
|
.frame(minWidth: 100)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background(Color(uiColor: .secondarySystemBackground))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(uiColor: .systemBackground))
|
||||||
|
.cornerRadius(12)
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
AccountsSummaryView()
|
||||||
|
.environmentObject(AccountsStore.shared)
|
||||||
|
.environmentObject(AppSettings.shared)
|
||||||
|
.padding()
|
||||||
|
.background(Color(uiColor: .systemBackground))
|
||||||
|
}
|
||||||
66
Coinly/Features/Accounts/AddAccountView.swift
Normal file
66
Coinly/Features/Accounts/AddAccountView.swift
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddAccountView: View {
|
||||||
|
@EnvironmentObject private var accountsStore: AccountsStore
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var type = AccountType.cash
|
||||||
|
@State private var balance = 0.0
|
||||||
|
@State private var currency = AppSettings.Currency.usd
|
||||||
|
@State private var isDefault = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField("Account Name", text: $name)
|
||||||
|
|
||||||
|
Picker("Type", selection: $type) {
|
||||||
|
ForEach(AccountType.allCases, id: \.self) { type in
|
||||||
|
Text(type.rawValue).tag(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CurrencyField("Initial Balance", value: $balance, currency: currency)
|
||||||
|
|
||||||
|
Picker("Currency", selection: $currency) {
|
||||||
|
ForEach(AppSettings.Currency.allCases, id: \.self) { currency in
|
||||||
|
Text(currency.rawValue).tag(currency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("Set as Default", isOn: $isDefault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("New Account")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Add") {
|
||||||
|
let account = AccountModel(
|
||||||
|
name: name,
|
||||||
|
balance: balance,
|
||||||
|
type: type,
|
||||||
|
icon: type.icon,
|
||||||
|
isDefault: isDefault,
|
||||||
|
currency: currency
|
||||||
|
)
|
||||||
|
accountsStore.addAccount(account)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.disabled(name.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
AddAccountView()
|
||||||
|
.environmentObject(AccountsStore.shared)
|
||||||
|
.environmentObject(AppSettings.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
89
Coinly/Features/Accounts/EditAccountView.swift
Normal file
89
Coinly/Features/Accounts/EditAccountView.swift
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct EditAccountView: View {
|
||||||
|
@EnvironmentObject private var accountsStore: AccountsStore
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
let account: AccountModel
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var type: AccountType
|
||||||
|
@State private var balance: Double
|
||||||
|
@State private var currency: AppSettings.Currency
|
||||||
|
@State private var isDefault: Bool
|
||||||
|
|
||||||
|
init(account: AccountModel) {
|
||||||
|
self.account = account
|
||||||
|
_name = State(initialValue: account.name)
|
||||||
|
_type = State(initialValue: account.type)
|
||||||
|
_balance = State(initialValue: account.balance)
|
||||||
|
_currency = State(initialValue: account.currency)
|
||||||
|
_isDefault = State(initialValue: account.isDefault)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField("Account Name", text: $name)
|
||||||
|
|
||||||
|
Picker("Type", selection: $type) {
|
||||||
|
ForEach(AccountType.allCases, id: \.self) { type in
|
||||||
|
Text(type.rawValue).tag(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CurrencyField("Balance", value: $balance, currency: currency)
|
||||||
|
|
||||||
|
Picker("Currency", selection: $currency) {
|
||||||
|
ForEach(AppSettings.Currency.allCases, id: \.self) { currency in
|
||||||
|
Text(currency.rawValue).tag(currency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("Set as Default", isOn: $isDefault)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button("Delete Account", role: .destructive) {
|
||||||
|
accountsStore.deleteAccount(account)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Edit Account")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
// В методе Save кнопки:
|
||||||
|
Button("Save") {
|
||||||
|
let updatedAccount = AccountModel(
|
||||||
|
id: account.id,
|
||||||
|
name: name,
|
||||||
|
balance: balance,
|
||||||
|
type: type,
|
||||||
|
icon: type.icon,
|
||||||
|
isDefault: isDefault,
|
||||||
|
currency: currency
|
||||||
|
)
|
||||||
|
if isDefault {
|
||||||
|
accountsStore.setDefaultAccount(updatedAccount)
|
||||||
|
} else {
|
||||||
|
accountsStore.updateAccount(updatedAccount)
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.disabled(name.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
EditAccountView(account: AccountModel.previewData)
|
||||||
|
.environmentObject(AccountsStore.shared)
|
||||||
|
.environmentObject(AppSettings.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
Coinly/Features/Accounts/SelectAccountTypeView.swift
Normal file
78
Coinly/Features/Accounts/SelectAccountTypeView.swift
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
// Path: Features/Accounts/SelectAccountTypeView.swift
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SelectAccountTypeView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
let onSelect: (AccountType) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(AccountType.allCases, id: \.self) { type in
|
||||||
|
Button {
|
||||||
|
onSelect(type)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: type.icon)
|
||||||
|
.foregroundColor(type.color)
|
||||||
|
.frame(width: 30)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(type.rawValue)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Text(descriptionFor(type))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundColor(Color(uiColor: .systemGray4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Select Account Type")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func descriptionFor(_ type: AccountType) -> String {
|
||||||
|
switch type {
|
||||||
|
case .wallet:
|
||||||
|
return "Digital wallet for everyday expenses"
|
||||||
|
case .cash:
|
||||||
|
return "Physical cash and money"
|
||||||
|
case .bankAccount:
|
||||||
|
return "Regular bank account"
|
||||||
|
case .creditCard:
|
||||||
|
return "Credit card with limit"
|
||||||
|
case .savings:
|
||||||
|
return "Long-term savings account"
|
||||||
|
case .investment:
|
||||||
|
return "Investment portfolio"
|
||||||
|
case .deposit:
|
||||||
|
return "Fixed term deposits"
|
||||||
|
case .debt:
|
||||||
|
return "Debts and loans"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
SelectAccountTypeView { type in
|
||||||
|
print("Selected type: \(type)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Coinly/Features/Budget/Models/BudgetModel.swift
Normal file
20
Coinly/Features/Budget/Models/BudgetModel.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
//
|
||||||
|
// BudgetModel.swift
|
||||||
|
// Coinly
|
||||||
|
//
|
||||||
|
// Created by Vadym Samoilenko on 03/03/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BudgetModel: Codable, Identifiable {
|
||||||
|
var id: String { categoryId }
|
||||||
|
let categoryId: String
|
||||||
|
var amount: Double
|
||||||
|
|
||||||
|
init(categoryId: String, amount: Double) {
|
||||||
|
self.categoryId = categoryId
|
||||||
|
self.amount = amount
|
||||||
|
}
|
||||||
|
}
|
||||||
54
Coinly/Features/Budget/Views/BudgetProgressView.swift
Normal file
54
Coinly/Features/Budget/Views/BudgetProgressView.swift
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
//
|
||||||
|
// BudgetProgressView.swift
|
||||||
|
// Coinly
|
||||||
|
//
|
||||||
|
// Created by Vadym Samoilenko on 03/03/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct BudgetProgressView: View {
|
||||||
|
let spent: Double
|
||||||
|
let total: Double
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
private var progress: Double {
|
||||||
|
guard total > 0 else { return 0 }
|
||||||
|
return min(spent / total, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var progressColor: Color {
|
||||||
|
if progress >= 1.0 {
|
||||||
|
return .red
|
||||||
|
} else if progress >= 0.8 {
|
||||||
|
return .orange
|
||||||
|
} else {
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(uiColor: .systemGray5))
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(progressColor)
|
||||||
|
.frame(width: geometry.size.width * progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 4)
|
||||||
|
.cornerRadius(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
BudgetProgressView(spent: 80, total: 100, color: .blue)
|
||||||
|
BudgetProgressView(spent: 90, total: 100, color: .blue)
|
||||||
|
BudgetProgressView(spent: 100, total: 100, color: .blue)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
48
Coinly/Features/Budget/Views/BudgetSettingsView.swift
Normal file
48
Coinly/Features/Budget/Views/BudgetSettingsView.swift
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
//
|
||||||
|
// BudgetSettingsView.swift
|
||||||
|
// Coinly
|
||||||
|
//
|
||||||
|
// Created by Vadym Samoilenko on 03/03/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct BudgetSettingsView: View {
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
@State private var showingMonthlyBudget = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
MonthlyBudgetSettingsView()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Monthly Budget")
|
||||||
|
Spacer()
|
||||||
|
Text(AppCurrencyFormatter.format(settings.monthlyBudget))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
CategoryBudgetSettingsView()
|
||||||
|
} label: {
|
||||||
|
Text("Category Budgets")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Budget Settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
BudgetSettingsView()
|
||||||
|
.environmentObject(AppSettings.shared)
|
||||||
|
.environmentObject(CategoryStore.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
Coinly/Features/Budget/Views/CategoryBudgetEditView.swift
Normal file
47
Coinly/Features/Budget/Views/CategoryBudgetEditView.swift
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CategoryBudgetEditView: View {
|
||||||
|
@EnvironmentObject private var categoryStore: CategoryStore
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let category: CategoryModel
|
||||||
|
@State private var budget: Double
|
||||||
|
|
||||||
|
init(category: CategoryModel) {
|
||||||
|
self.category = category
|
||||||
|
self._budget = State(initialValue: CategoryStore.shared.getBudget(for: category.id)?.amount ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
CurrencyField(
|
||||||
|
"Budget Amount",
|
||||||
|
value: $budget,
|
||||||
|
currency: settings.selectedCurrency
|
||||||
|
)
|
||||||
|
} footer: {
|
||||||
|
Text("Set monthly budget for \(category.name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(category.name)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Save") {
|
||||||
|
categoryStore.setBudget(budget, for: category.id)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
CategoryBudgetEditView(category: CategoryModel.sampleData)
|
||||||
|
.environmentObject(CategoryStore.shared)
|
||||||
|
.environmentObject(AppSettings.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
Coinly/Features/Budget/Views/CategoryBudgetRowView.swift
Normal file
32
Coinly/Features/Budget/Views/CategoryBudgetRowView.swift
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CategoryBudgetRowView: View {
|
||||||
|
@EnvironmentObject private var categoryStore: CategoryStore
|
||||||
|
let category: CategoryModel
|
||||||
|
|
||||||
|
private var budget: BudgetModel? {
|
||||||
|
categoryStore.getBudget(for: category.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
CategoryRowView(category: category)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let budget = budget {
|
||||||
|
Text(AppCurrencyFormatter.format(budget.amount))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("Not Set")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
CategoryBudgetRowView(category: CategoryModel.sampleData)
|
||||||
|
.environmentObject(CategoryStore.shared)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CategoryBudgetSettingsView: View {
|
||||||
|
@EnvironmentObject private var categoryStore: CategoryStore
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(categoryStore.getCategories(of: .expense)) { category in
|
||||||
|
NavigationLink {
|
||||||
|
CategoryBudgetEditView(category: category)
|
||||||
|
} label: {
|
||||||
|
CategoryBudgetRowView(category: category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Category Budgets")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
CategoryBudgetSettingsView()
|
||||||
|
.environmentObject(CategoryStore.shared)
|
||||||
|
.environmentObject(AppSettings.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
Coinly/Features/Budget/Views/MonthlyBudgetSettingsView.swift
Normal file
47
Coinly/Features/Budget/Views/MonthlyBudgetSettingsView.swift
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
//
|
||||||
|
// MonthlyBudgetSettingsView.swift
|
||||||
|
// Coinly
|
||||||
|
//
|
||||||
|
// Created by Vadym Samoilenko on 03/03/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MonthlyBudgetSettingsView: View {
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
@State private var monthlyBudget: Double
|
||||||
|
|
||||||
|
init() {
|
||||||
|
_monthlyBudget = State(initialValue: AppSettings.shared.monthlyBudget)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
CurrencyField(
|
||||||
|
"Monthly Budget",
|
||||||
|
value: Binding(
|
||||||
|
get: { monthlyBudget },
|
||||||
|
set: {
|
||||||
|
monthlyBudget = $0
|
||||||
|
settings.monthlyBudget = $0
|
||||||
|
}
|
||||||
|
),
|
||||||
|
currency: settings.selectedCurrency
|
||||||
|
)
|
||||||
|
} footer: {
|
||||||
|
Text("Set your monthly budget to track your spending")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Monthly Budget")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
MonthlyBudgetSettingsView()
|
||||||
|
.environmentObject(AppSettings.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
92
Coinly/Features/Categories/AddCategoryView.swift
Normal file
92
Coinly/Features/Categories/AddCategoryView.swift
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddCategoryView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
let type: TransactionType
|
||||||
|
let onSave: (CategoryModel) -> Void
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var selectedIcon = "cart.fill"
|
||||||
|
@State private var selectedColor = "systemBlue"
|
||||||
|
|
||||||
|
private let icons = [
|
||||||
|
"cart.fill", "car.fill", "house.fill", "creditcard.fill",
|
||||||
|
"gift.fill", "heart.fill", "tv.fill", "gamecontroller.fill",
|
||||||
|
"airplane", "cross.fill", "doc.text.fill", "bag.fill",
|
||||||
|
"dollarsign.circle.fill", "chart.line.uptrend.xyaxis",
|
||||||
|
"briefcase.fill", "gift.fill", "leaf.fill", "graduationcap.fill"
|
||||||
|
]
|
||||||
|
|
||||||
|
private let colors = [
|
||||||
|
"systemRed", "systemBlue", "systemGreen", "systemYellow",
|
||||||
|
"systemPurple", "systemOrange", "systemPink", "systemIndigo"
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Basic Information") {
|
||||||
|
TextField("Category Name", text: $name)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Icon") {
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: 16) {
|
||||||
|
ForEach(icons, id: \.self) { icon in
|
||||||
|
Button {
|
||||||
|
selectedIcon = icon
|
||||||
|
} label: {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title2)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(selectedIcon == icon ? Color.accentColor : Color.clear)
|
||||||
|
.foregroundColor(selectedIcon == icon ? .white : .primary)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Color") {
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 8), spacing: 16) {
|
||||||
|
ForEach(colors, id: \.self) { color in
|
||||||
|
Button {
|
||||||
|
selectedColor = color
|
||||||
|
} label: {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(color))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.overlay {
|
||||||
|
if selectedColor == color {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("New Category")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") {
|
||||||
|
let category = CategoryModel(
|
||||||
|
name: name,
|
||||||
|
icon: selectedIcon,
|
||||||
|
color: selectedColor,
|
||||||
|
type: type
|
||||||
|
)
|
||||||
|
onSave(category)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.disabled(name.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Coinly/Features/Categories/CategoryListView.swift
Normal file
48
Coinly/Features/Categories/CategoryListView.swift
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CategoryListView: View {
|
||||||
|
@EnvironmentObject private var categoryStore: CategoryStore
|
||||||
|
@State private var showingAddCategory = false
|
||||||
|
let type: TransactionType
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(categoryStore.getCategories(of: type)) { category in
|
||||||
|
CategoryRowView(category: category)
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
|
if !category.isDefault {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
categoryStore.deleteCategory(category)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(type == .income ? "Income Categories" : "Expense Categories")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showingAddCategory = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingAddCategory) {
|
||||||
|
NavigationView {
|
||||||
|
AddCategoryView(type: type) { category in
|
||||||
|
categoryStore.addCategory(category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
CategoryListView(type: .expense)
|
||||||
|
.environmentObject(CategoryStore.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
140
Coinly/Features/Dashboard/DashboardView.swift
Normal file
140
Coinly/Features/Dashboard/DashboardView.swift
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DashboardView: View {
|
||||||
|
@EnvironmentObject private var accountsStore: AccountsStore
|
||||||
|
@EnvironmentObject private var transactionsStore: TransactionsStore
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
@State private var showingAddTransaction = false
|
||||||
|
@State private var showingFilter = false
|
||||||
|
@State private var currentFilter = TransactionFilter()
|
||||||
|
@State private var selectedAccountId: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack(spacing: 12) {
|
||||||
|
ForEach(accountsStore.accounts) { account in
|
||||||
|
AccountCardView(
|
||||||
|
account: account,
|
||||||
|
isSelected: selectedAccountId == account.id
|
||||||
|
)
|
||||||
|
.onTapGesture {
|
||||||
|
selectedAccountId = account.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.frame(height: 160)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
StatCard(
|
||||||
|
title: "Income",
|
||||||
|
amount: totalIncome,
|
||||||
|
color: .green,
|
||||||
|
currency: settings.selectedCurrency
|
||||||
|
)
|
||||||
|
|
||||||
|
StatCard(
|
||||||
|
title: "Expenses",
|
||||||
|
amount: totalExpenses,
|
||||||
|
color: .red,
|
||||||
|
currency: settings.selectedCurrency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach(filteredTransactions) { transaction in
|
||||||
|
TransactionRowView(transaction: transaction)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
Text("Recent Transactions")
|
||||||
|
Spacer()
|
||||||
|
Button("See All") {
|
||||||
|
// Navigate to transactions
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Dashboard")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showingAddTransaction = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
.disabled(accountsStore.accounts.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingAddTransaction) {
|
||||||
|
NavigationView {
|
||||||
|
AddTransactionView { transaction in
|
||||||
|
handleAddTransaction(transaction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingFilter) {
|
||||||
|
NavigationView {
|
||||||
|
TransactionFilterView(filter: $currentFilter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filteredTransactions: [TransactionModel] {
|
||||||
|
transactionsStore.filterTransactions(filter: currentFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalIncome: Double {
|
||||||
|
filteredTransactions
|
||||||
|
.filter { $0.type == .income }
|
||||||
|
.reduce(0) { $0 + $1.amount }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalExpenses: Double {
|
||||||
|
filteredTransactions
|
||||||
|
.filter { $0.type == .expense }
|
||||||
|
.reduce(0) { $0 + $1.amount }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAddTransaction(_ transaction: TransactionModel) {
|
||||||
|
transactionsStore.addTransaction(transaction)
|
||||||
|
if let account = accountsStore.getAccount(withId: transaction.accountId ?? "") {
|
||||||
|
let newBalance = account.balance + (transaction.type == .income ? transaction.amount : -transaction.amount)
|
||||||
|
accountsStore.updateAccount(account.with(balance: newBalance))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StatCard: View {
|
||||||
|
let title: String
|
||||||
|
let amount: Double
|
||||||
|
let color: Color
|
||||||
|
let currency: AppSettings.Currency
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(amount.formatted(.currency(code: currency.rawValue)))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(color)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
DashboardView()
|
||||||
|
.environmentObject(AccountsStore.shared)
|
||||||
|
.environmentObject(TransactionsStore.shared)
|
||||||
|
.environmentObject(AppSettings.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
Coinly/Features/Models/AccountModel.swift
Normal file
94
Coinly/Features/Models/AccountModel.swift
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct AccountModel: Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
var balance: Double
|
||||||
|
let type: AccountType
|
||||||
|
let icon: String
|
||||||
|
let isDefault: Bool
|
||||||
|
let currency: AppSettings.Currency
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
name: String,
|
||||||
|
balance: Double,
|
||||||
|
type: AccountType,
|
||||||
|
icon: String,
|
||||||
|
isDefault: Bool = false,
|
||||||
|
currency: AppSettings.Currency
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.balance = balance
|
||||||
|
self.type = type
|
||||||
|
self.icon = icon
|
||||||
|
self.isDefault = isDefault
|
||||||
|
self.currency = currency
|
||||||
|
}
|
||||||
|
|
||||||
|
func with(balance: Double) -> AccountModel {
|
||||||
|
AccountModel(
|
||||||
|
id: self.id,
|
||||||
|
name: self.name,
|
||||||
|
balance: balance,
|
||||||
|
type: self.type,
|
||||||
|
icon: self.icon,
|
||||||
|
isDefault: self.isDefault,
|
||||||
|
currency: self.currency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func with(isDefault: Bool) -> AccountModel {
|
||||||
|
AccountModel(
|
||||||
|
id: self.id,
|
||||||
|
name: self.name,
|
||||||
|
balance: self.balance,
|
||||||
|
type: self.type,
|
||||||
|
icon: self.icon,
|
||||||
|
isDefault: isDefault,
|
||||||
|
currency: self.currency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: AccountModel, rhs: AccountModel) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview Data
|
||||||
|
extension AccountModel {
|
||||||
|
static let previewData = AccountModel(
|
||||||
|
id: "preview",
|
||||||
|
name: "Cash",
|
||||||
|
balance: 1000,
|
||||||
|
type: .cash,
|
||||||
|
icon: "banknote",
|
||||||
|
isDefault: true,
|
||||||
|
currency: .usd
|
||||||
|
)
|
||||||
|
|
||||||
|
static let previewItems: [AccountModel] = [
|
||||||
|
previewData,
|
||||||
|
AccountModel(
|
||||||
|
id: "bank",
|
||||||
|
name: "Bank Account",
|
||||||
|
balance: 5000,
|
||||||
|
type: .bankAccount,
|
||||||
|
icon: "building.columns.fill",
|
||||||
|
currency: .usd
|
||||||
|
),
|
||||||
|
AccountModel(
|
||||||
|
id: "credit",
|
||||||
|
name: "Credit Card",
|
||||||
|
balance: -500,
|
||||||
|
type: .creditCard,
|
||||||
|
icon: "creditcard.fill",
|
||||||
|
currency: .usd
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
38
Coinly/Features/Models/AccountType.swift
Normal file
38
Coinly/Features/Models/AccountType.swift
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import SwiftUI // Изменили import Foundation на SwiftUI для доступа к Color
|
||||||
|
|
||||||
|
enum AccountType: String, Codable, CaseIterable, Equatable {
|
||||||
|
case wallet = "Wallet"
|
||||||
|
case cash = "Cash"
|
||||||
|
case bankAccount = "Bank Account"
|
||||||
|
case creditCard = "Credit Card"
|
||||||
|
case savings = "Savings"
|
||||||
|
case investment = "Investment"
|
||||||
|
case deposit = "Deposit"
|
||||||
|
case debt = "Debt"
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .wallet: return "wallet.pass"
|
||||||
|
case .cash: return "banknote"
|
||||||
|
case .bankAccount: return "building.columns"
|
||||||
|
case .creditCard: return "creditcard"
|
||||||
|
case .savings: return "piggybank"
|
||||||
|
case .investment: return "chart.line.uptrend.xyaxis"
|
||||||
|
case .deposit: return "arrow.down.circle"
|
||||||
|
case .debt: return "arrow.up.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch self {
|
||||||
|
case .wallet: return .blue
|
||||||
|
case .cash: return .green
|
||||||
|
case .bankAccount: return .purple
|
||||||
|
case .creditCard: return .orange
|
||||||
|
case .savings: return .yellow
|
||||||
|
case .investment: return .red
|
||||||
|
case .deposit: return .mint
|
||||||
|
case .debt: return .pink
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
Coinly/Features/Models/AccountsStore.swift
Normal file
93
Coinly/Features/Models/AccountsStore.swift
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class AccountsStore: ObservableObject {
|
||||||
|
static let shared = AccountsStore()
|
||||||
|
|
||||||
|
@Published private(set) var accounts: [AccountModel] = []
|
||||||
|
@Published private(set) var defaultAccountId: String?
|
||||||
|
|
||||||
|
var totalBalance: Double {
|
||||||
|
accounts.reduce(0) { $0 + $1.balance }
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultAccount: AccountModel? {
|
||||||
|
accounts.first { $0.id == defaultAccountId }
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
#if DEBUG
|
||||||
|
self.accounts = AccountModel.previewItems
|
||||||
|
if let firstAccount = accounts.first {
|
||||||
|
self.defaultAccountId = firstAccount.id
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAccount(_ account: AccountModel) {
|
||||||
|
if account.isDefault {
|
||||||
|
defaultAccountId = account.id
|
||||||
|
// Remove default flag from other accounts
|
||||||
|
accounts = accounts.map { existingAccount in
|
||||||
|
existingAccount.with(isDefault: false)
|
||||||
|
}
|
||||||
|
} else if accounts.isEmpty {
|
||||||
|
// Make first account default
|
||||||
|
defaultAccountId = account.id
|
||||||
|
let newAccount = account.with(isDefault: true)
|
||||||
|
accounts.append(newAccount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accounts.append(account)
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAccount(_ account: AccountModel) {
|
||||||
|
guard let index = accounts.firstIndex(where: { $0.id == account.id }) else { return }
|
||||||
|
|
||||||
|
if account.isDefault {
|
||||||
|
defaultAccountId = account.id
|
||||||
|
// Remove default flag from other accounts
|
||||||
|
accounts = accounts.map { existingAccount in
|
||||||
|
if existingAccount.id == account.id {
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
return existingAccount.with(isDefault: false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If this was the default account, make sure we have a new default
|
||||||
|
if accounts[index].isDefault {
|
||||||
|
if let firstOtherAccount = accounts.first(where: { $0.id != account.id }) {
|
||||||
|
defaultAccountId = firstOtherAccount.id
|
||||||
|
updateAccount(firstOtherAccount.with(isDefault: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accounts[index] = account
|
||||||
|
}
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAccount(_ account: AccountModel) {
|
||||||
|
accounts.removeAll { $0.id == account.id }
|
||||||
|
|
||||||
|
// If we deleted the default account, make another one default
|
||||||
|
if account.isDefault {
|
||||||
|
if let firstAccount = accounts.first {
|
||||||
|
defaultAccountId = firstAccount.id
|
||||||
|
updateAccount(firstAccount.with(isDefault: true))
|
||||||
|
} else {
|
||||||
|
defaultAccountId = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDefaultAccount(_ account: AccountModel) {
|
||||||
|
defaultAccountId = account.id
|
||||||
|
updateAccount(account.with(isDefault: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAccount(withId id: String) -> AccountModel? {
|
||||||
|
accounts.first { $0.id == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Coinly/Features/Models/AppSettings.swift
Normal file
25
Coinly/Features/Models/AppSettings.swift
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
class AppSettings: ObservableObject {
|
||||||
|
static let shared = AppSettings()
|
||||||
|
|
||||||
|
@Published var selectedCurrency: Currency = .usd
|
||||||
|
@Published var monthlyBudget: Double = 0
|
||||||
|
@Published var colorScheme: ColorScheme? = nil // Используем ColorScheme из SwiftUI
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
enum Currency: String, Codable, CaseIterable {
|
||||||
|
case usd = "USD"
|
||||||
|
case eur = "EUR"
|
||||||
|
case gbp = "GBP"
|
||||||
|
|
||||||
|
var symbol: String {
|
||||||
|
switch self {
|
||||||
|
case .usd: return "$"
|
||||||
|
case .eur: return "€"
|
||||||
|
case .gbp: return "£"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
Coinly/Features/Models/CategoryModel.swift
Normal file
69
Coinly/Features/Models/CategoryModel.swift
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CategoryModel: Identifiable, Codable {
|
||||||
|
let id: String
|
||||||
|
var name: String
|
||||||
|
var icon: String
|
||||||
|
var color: String
|
||||||
|
var type: TransactionType
|
||||||
|
var isDefault: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
name: String,
|
||||||
|
icon: String,
|
||||||
|
color: String,
|
||||||
|
type: TransactionType,
|
||||||
|
isDefault: Bool = false
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.icon = icon
|
||||||
|
self.color = color
|
||||||
|
self.type = type
|
||||||
|
self.isDefault = isDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
func iconColor() -> Color {
|
||||||
|
Color(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let sampleData = CategoryModel(
|
||||||
|
name: "Food",
|
||||||
|
icon: "cart.fill",
|
||||||
|
color: "systemRed",
|
||||||
|
type: .expense,
|
||||||
|
isDefault: true
|
||||||
|
)
|
||||||
|
|
||||||
|
static let sampleCategories: [CategoryModel] = [
|
||||||
|
CategoryModel(
|
||||||
|
name: "Food",
|
||||||
|
icon: "cart.fill",
|
||||||
|
color: "systemRed",
|
||||||
|
type: .expense,
|
||||||
|
isDefault: true
|
||||||
|
),
|
||||||
|
CategoryModel(
|
||||||
|
name: "Transport",
|
||||||
|
icon: "car.fill",
|
||||||
|
color: "systemBlue",
|
||||||
|
type: .expense,
|
||||||
|
isDefault: true
|
||||||
|
),
|
||||||
|
CategoryModel(
|
||||||
|
name: "Shopping",
|
||||||
|
icon: "bag.fill",
|
||||||
|
color: "systemGreen",
|
||||||
|
type: .expense,
|
||||||
|
isDefault: true
|
||||||
|
),
|
||||||
|
CategoryModel(
|
||||||
|
name: "Salary",
|
||||||
|
icon: "dollarsign.circle.fill",
|
||||||
|
color: "systemPurple",
|
||||||
|
type: .income,
|
||||||
|
isDefault: true
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
84
Coinly/Features/Models/CategoryStore.swift
Normal file
84
Coinly/Features/Models/CategoryStore.swift
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class CategoryStore: ObservableObject {
|
||||||
|
static let shared = CategoryStore()
|
||||||
|
|
||||||
|
@Published private(set) var categories: [CategoryModel] = []
|
||||||
|
@Published private(set) var budgets: [BudgetModel] = []
|
||||||
|
|
||||||
|
private static let defaultCategories: [CategoryModel] = [
|
||||||
|
CategoryModel(name: "Salary", icon: "dollarsign.circle.fill", color: "systemGreen", type: .income, isDefault: true),
|
||||||
|
CategoryModel(name: "Food", icon: "cart.fill", color: "systemRed", type: .expense, isDefault: true),
|
||||||
|
CategoryModel(name: "Transport", icon: "car.fill", color: "systemBlue", type: .expense, isDefault: true),
|
||||||
|
CategoryModel(name: "Shopping", icon: "cart.fill", color: "systemOrange", type: .expense, isDefault: true),
|
||||||
|
CategoryModel(name: "Entertainment", icon: "film.fill", color: "systemPurple", type: .expense, isDefault: true)
|
||||||
|
]
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadCategories()
|
||||||
|
loadBudgets()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Category Operations
|
||||||
|
|
||||||
|
func getCategories(of type: TransactionType) -> [CategoryModel] {
|
||||||
|
categories.filter { $0.type == type }
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCategory(withId id: String) -> CategoryModel? {
|
||||||
|
categories.first { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func addCategory(_ category: CategoryModel) {
|
||||||
|
categories.append(category)
|
||||||
|
saveCategories()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteCategory(_ category: CategoryModel) {
|
||||||
|
guard !category.isDefault else { return }
|
||||||
|
categories.removeAll { $0.id == category.id }
|
||||||
|
saveCategories()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateCategory(_ category: CategoryModel) {
|
||||||
|
if let index = categories.firstIndex(where: { $0.id == category.id }) {
|
||||||
|
categories[index] = category
|
||||||
|
saveCategories()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Budget Operations
|
||||||
|
|
||||||
|
func setBudget(_ amount: Double, for categoryId: String) {
|
||||||
|
if let index = budgets.firstIndex(where: { $0.categoryId == categoryId }) {
|
||||||
|
budgets[index].amount = amount
|
||||||
|
} else {
|
||||||
|
budgets.append(BudgetModel(categoryId: categoryId, amount: amount))
|
||||||
|
}
|
||||||
|
saveBudgets()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBudget(for categoryId: String) -> BudgetModel? {
|
||||||
|
budgets.first { $0.categoryId == categoryId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Storage
|
||||||
|
|
||||||
|
private func loadCategories() {
|
||||||
|
// TODO: Implement actual persistence
|
||||||
|
categories = Self.defaultCategories
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveCategories() {
|
||||||
|
// TODO: Implement actual persistence
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadBudgets() {
|
||||||
|
// TODO: Implement actual persistence
|
||||||
|
budgets = []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveBudgets() {
|
||||||
|
// TODO: Implement actual persistence
|
||||||
|
}
|
||||||
|
}
|
||||||
23
Coinly/Features/Models/DebtPayment.swift
Normal file
23
Coinly/Features/Models/DebtPayment.swift
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
//
|
||||||
|
// DebtPayment.swift
|
||||||
|
// Coinly
|
||||||
|
//
|
||||||
|
// Created by Vadym Samoilenko on 02/03/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct DebtPayment: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
var dueDate: Date
|
||||||
|
var amount: Double
|
||||||
|
var isPaid: Bool
|
||||||
|
|
||||||
|
init(id: String = UUID().uuidString, dueDate: Date, amount: Double, isPaid: Bool = false) {
|
||||||
|
self.id = id
|
||||||
|
self.dueDate = dueDate
|
||||||
|
self.amount = amount
|
||||||
|
self.isPaid = isPaid
|
||||||
|
}
|
||||||
|
}
|
||||||
70
Coinly/Features/Models/TransactionFilter.swift
Normal file
70
Coinly/Features/Models/TransactionFilter.swift
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// Features/Models/TransactionFilter.swift
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TransactionFilter {
|
||||||
|
var startDate: Date?
|
||||||
|
var endDate: Date?
|
||||||
|
var type: TransactionType?
|
||||||
|
var categoryId: String?
|
||||||
|
var accountId: String?
|
||||||
|
var period: Period?
|
||||||
|
|
||||||
|
init(
|
||||||
|
startDate: Date? = nil,
|
||||||
|
endDate: Date? = nil,
|
||||||
|
type: TransactionType? = nil,
|
||||||
|
categoryId: String? = nil,
|
||||||
|
accountId: String? = nil,
|
||||||
|
period: Period? = nil
|
||||||
|
) {
|
||||||
|
self.startDate = startDate
|
||||||
|
self.endDate = endDate
|
||||||
|
self.type = type
|
||||||
|
self.categoryId = categoryId
|
||||||
|
self.accountId = accountId
|
||||||
|
self.period = period
|
||||||
|
|
||||||
|
if let period = period {
|
||||||
|
self.startDate = period.dateInterval.start
|
||||||
|
self.endDate = period.dateInterval.end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Period: String, CaseIterable {
|
||||||
|
case day = "Today"
|
||||||
|
case week = "This Week"
|
||||||
|
case month = "This Month"
|
||||||
|
case year = "This Year"
|
||||||
|
|
||||||
|
var dateInterval: DateInterval {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
switch self {
|
||||||
|
case .day:
|
||||||
|
let start = calendar.startOfDay(for: now)
|
||||||
|
let end = calendar.date(byAdding: .day, value: 1, to: start) ?? now
|
||||||
|
return DateInterval(start: start, end: end)
|
||||||
|
|
||||||
|
case .week:
|
||||||
|
if let interval = calendar.dateInterval(of: .weekOfMonth, for: now) {
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
return DateInterval(start: now, end: now)
|
||||||
|
|
||||||
|
case .month:
|
||||||
|
if let interval = calendar.dateInterval(of: .month, for: now) {
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
return DateInterval(start: now, end: now)
|
||||||
|
|
||||||
|
case .year:
|
||||||
|
if let interval = calendar.dateInterval(of: .year, for: now) {
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
return DateInterval(start: now, end: now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
Coinly/Features/Models/TransactionModel.swift
Normal file
69
Coinly/Features/Models/TransactionModel.swift
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TransactionModel: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let amount: Double
|
||||||
|
let type: TransactionType
|
||||||
|
let category: String
|
||||||
|
let categoryId: String?
|
||||||
|
let note: String?
|
||||||
|
let date: Date
|
||||||
|
let accountId: String?
|
||||||
|
let originalCurrency: AppSettings.Currency
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
amount: Double,
|
||||||
|
type: TransactionType,
|
||||||
|
category: String,
|
||||||
|
categoryId: String?,
|
||||||
|
note: String? = nil,
|
||||||
|
date: Date = Date(),
|
||||||
|
accountId: String?,
|
||||||
|
originalCurrency: AppSettings.Currency
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.amount = amount
|
||||||
|
self.type = type
|
||||||
|
self.category = category
|
||||||
|
self.categoryId = categoryId
|
||||||
|
self.note = note
|
||||||
|
self.date = date
|
||||||
|
self.accountId = accountId
|
||||||
|
self.originalCurrency = originalCurrency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview Data
|
||||||
|
extension TransactionModel {
|
||||||
|
static let previewData = TransactionModel(
|
||||||
|
amount: 42.50,
|
||||||
|
type: .expense,
|
||||||
|
category: "Food",
|
||||||
|
categoryId: "food",
|
||||||
|
note: "Lunch",
|
||||||
|
accountId: AccountModel.previewData.id,
|
||||||
|
originalCurrency: .usd
|
||||||
|
)
|
||||||
|
|
||||||
|
static let previewItems: [TransactionModel] = [
|
||||||
|
previewData,
|
||||||
|
TransactionModel(
|
||||||
|
amount: 1000,
|
||||||
|
type: .income,
|
||||||
|
category: "Salary",
|
||||||
|
categoryId: "salary",
|
||||||
|
note: "Monthly salary",
|
||||||
|
accountId: AccountModel.previewData.id,
|
||||||
|
originalCurrency: .usd
|
||||||
|
),
|
||||||
|
TransactionModel(
|
||||||
|
amount: 15.99,
|
||||||
|
type: .expense,
|
||||||
|
category: "Entertainment",
|
||||||
|
categoryId: "entertainment",
|
||||||
|
accountId: AccountModel.previewData.id,
|
||||||
|
originalCurrency: .usd
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
10
Coinly/Features/Models/TransactionType.swift
Normal file
10
Coinly/Features/Models/TransactionType.swift
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum TransactionType: String, Codable, CaseIterable, Hashable {
|
||||||
|
case expense = "Expense"
|
||||||
|
case income = "Income"
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
self.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Coinly/Features/Profile/ProfileView.swift
Normal file
41
Coinly/Features/Profile/ProfileView.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ProfileView: View {
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section("Appearance") {
|
||||||
|
Picker("Color Scheme", selection: $settings.colorScheme.animation()) {
|
||||||
|
Text("System").tag(nil as ColorScheme?)
|
||||||
|
Text("Light").tag(ColorScheme.light as ColorScheme?)
|
||||||
|
Text("Dark").tag(ColorScheme.dark as ColorScheme?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Currency") {
|
||||||
|
Picker("Currency", selection: $settings.selectedCurrency) {
|
||||||
|
ForEach(AppSettings.Currency.allCases, id: \.self) { currency in
|
||||||
|
Text(currency.rawValue).tag(currency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Budget") {
|
||||||
|
NavigationLink {
|
||||||
|
BudgetSettingsView()
|
||||||
|
} label: {
|
||||||
|
Text("Budget Settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
ProfileView()
|
||||||
|
.environmentObject(AppSettings.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
133
Coinly/Features/Transactions/AddTransactionView.swift
Normal file
133
Coinly/Features/Transactions/AddTransactionView.swift
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddTransactionView: View {
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
@EnvironmentObject private var accountsStore: AccountsStore
|
||||||
|
@EnvironmentObject private var categoryStore: CategoryStore
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let onSave: (TransactionModel) -> Void
|
||||||
|
|
||||||
|
@State private var amount: Double = 0
|
||||||
|
@State private var type: TransactionType = .expense
|
||||||
|
@State private var category: CategoryModel?
|
||||||
|
@State private var note: String = ""
|
||||||
|
@State private var date = Date()
|
||||||
|
@State private var account: AccountModel?
|
||||||
|
@State private var showingCategoryPicker = false
|
||||||
|
@State private var showingAccountPicker = false
|
||||||
|
|
||||||
|
private var isFormValid: Bool {
|
||||||
|
amount > 0 && category != nil && account != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Picker("Type", selection: $type) {
|
||||||
|
Text("Expense").tag(TransactionType.expense)
|
||||||
|
Text("Income").tag(TransactionType.income)
|
||||||
|
}
|
||||||
|
.onChange(of: type) { oldValue, newValue in
|
||||||
|
category = nil
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
CurrencyField("Amount", value: $amount, currency: settings.selectedCurrency)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
showingCategoryPicker = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Category")
|
||||||
|
Spacer()
|
||||||
|
if let category = category {
|
||||||
|
CategoryRowView(category: category)
|
||||||
|
} else {
|
||||||
|
Text("Select Category")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showingAccountPicker = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Account")
|
||||||
|
Spacer()
|
||||||
|
if let account = account {
|
||||||
|
Text(account.name)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
} else {
|
||||||
|
Text("Select Account")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
DatePicker("Date", selection: $date, displayedComponents: [.date, .hourAndMinute])
|
||||||
|
|
||||||
|
TextField("Note", text: $note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("New Transaction")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Add") {
|
||||||
|
guard let category = category, let account = account else { return }
|
||||||
|
|
||||||
|
let transaction = TransactionModel(
|
||||||
|
amount: amount,
|
||||||
|
type: type,
|
||||||
|
category: category.name,
|
||||||
|
categoryId: category.id,
|
||||||
|
note: note.isEmpty ? nil : note,
|
||||||
|
date: date,
|
||||||
|
accountId: account.id,
|
||||||
|
originalCurrency: settings.selectedCurrency
|
||||||
|
)
|
||||||
|
|
||||||
|
onSave(transaction)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.disabled(!isFormValid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingCategoryPicker) {
|
||||||
|
NavigationView {
|
||||||
|
CategoryPickerView(
|
||||||
|
transactionType: type,
|
||||||
|
onSelect: { category = $0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingAccountPicker) {
|
||||||
|
NavigationView {
|
||||||
|
AccountPickerView(
|
||||||
|
onSelect: { account = $0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
AddTransactionView { _ in }
|
||||||
|
.environmentObject(AppSettings.shared)
|
||||||
|
.environmentObject(AccountsStore.shared)
|
||||||
|
.environmentObject(CategoryStore.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
104
Coinly/Features/Transactions/TransactionFilterView.swift
Normal file
104
Coinly/Features/Transactions/TransactionFilterView.swift
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TransactionFilterView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Binding var filter: TransactionFilter
|
||||||
|
@State private var temporaryFilter: TransactionFilter
|
||||||
|
@EnvironmentObject private var accountsStore: AccountsStore
|
||||||
|
@EnvironmentObject private var categoryStore: CategoryStore
|
||||||
|
|
||||||
|
init(filter: Binding<TransactionFilter>) {
|
||||||
|
self._filter = filter
|
||||||
|
self._temporaryFilter = State(initialValue: filter.wrappedValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Period") {
|
||||||
|
Picker("Period", selection: $temporaryFilter.period) {
|
||||||
|
Text("All Time").tag(Optional<TransactionFilter.Period>.none)
|
||||||
|
ForEach(TransactionFilter.Period.allCases, id: \.self) { period in
|
||||||
|
Text(period.rawValue).tag(Optional(period))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Type") {
|
||||||
|
Picker("Type", selection: $temporaryFilter.type) {
|
||||||
|
Text("All").tag(Optional<TransactionType>.none)
|
||||||
|
ForEach(TransactionType.allCases, id: \.self) { type in
|
||||||
|
Text(type.rawValue).tag(Optional(type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Account") {
|
||||||
|
NavigationLink {
|
||||||
|
AccountPickerView { account in
|
||||||
|
temporaryFilter.accountId = account.id
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Account")
|
||||||
|
Spacer()
|
||||||
|
if let accountId = temporaryFilter.accountId,
|
||||||
|
let account = accountsStore.getAccount(withId: accountId) {
|
||||||
|
Text(account.name)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("All")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Category") {
|
||||||
|
NavigationLink {
|
||||||
|
CategoryPickerView(
|
||||||
|
transactionType: temporaryFilter.type ?? .expense
|
||||||
|
) { category in
|
||||||
|
temporaryFilter.categoryId = category.id
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Category")
|
||||||
|
Spacer()
|
||||||
|
if let categoryId = temporaryFilter.categoryId,
|
||||||
|
let category = categoryStore.getCategory(withId: categoryId) {
|
||||||
|
Text(category.name)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("All")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Filter")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Reset") {
|
||||||
|
temporaryFilter = TransactionFilter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Apply") {
|
||||||
|
filter = temporaryFilter
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
TransactionFilterView(filter: .constant(TransactionFilter()))
|
||||||
|
.environmentObject(AccountsStore.shared)
|
||||||
|
.environmentObject(CategoryStore.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
Coinly/Features/Transactions/TransactionRowView.swift
Normal file
45
Coinly/Features/Transactions/TransactionRowView.swift
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Features/Transactions/TransactionRowView.swift
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TransactionRowView: View {
|
||||||
|
@EnvironmentObject private var categoryStore: CategoryStore
|
||||||
|
let transaction: TransactionModel
|
||||||
|
|
||||||
|
private var category: CategoryModel? {
|
||||||
|
guard let categoryId = transaction.categoryId else { return nil }
|
||||||
|
return categoryStore.getCategory(withId: categoryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
if let category = category {
|
||||||
|
CategoryRowView(category: category)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "questionmark.circle.fill")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(transaction.category)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text(transaction.amount.formatted(.currency(code: transaction.originalCurrency.rawValue)))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(transaction.type == .income ? .green : .primary)
|
||||||
|
|
||||||
|
Text(transaction.date.formatted(date: .abbreviated, time: .shortened))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
TransactionRowView(transaction: TransactionModel.previewData)
|
||||||
|
.environmentObject(CategoryStore.shared)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
60
Coinly/Features/Transactions/TransactionsStore.swift
Normal file
60
Coinly/Features/Transactions/TransactionsStore.swift
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class TransactionsStore: ObservableObject {
|
||||||
|
static let shared = TransactionsStore()
|
||||||
|
|
||||||
|
@Published private(set) var transactions: [TransactionModel] = []
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
#if DEBUG
|
||||||
|
self.transactions = TransactionModel.previewItems
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTransaction(_ transaction: TransactionModel) {
|
||||||
|
transactions.append(transaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterTransactions(filter: TransactionFilter) -> [TransactionModel] {
|
||||||
|
transactions.filter { transaction in
|
||||||
|
var matches = true
|
||||||
|
|
||||||
|
if let startDate = filter.startDate {
|
||||||
|
matches = matches && transaction.date >= startDate
|
||||||
|
}
|
||||||
|
|
||||||
|
if let endDate = filter.endDate {
|
||||||
|
matches = matches && transaction.date < endDate
|
||||||
|
}
|
||||||
|
|
||||||
|
if let type = filter.type {
|
||||||
|
matches = matches && transaction.type == type
|
||||||
|
}
|
||||||
|
|
||||||
|
if let categoryId = filter.categoryId {
|
||||||
|
matches = matches && transaction.categoryId == categoryId
|
||||||
|
}
|
||||||
|
|
||||||
|
if let accountId = filter.accountId {
|
||||||
|
matches = matches && transaction.accountId == accountId
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
.sorted { $0.date > $1.date }
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteTransaction(_ transaction: TransactionModel) {
|
||||||
|
transactions.removeAll { $0.id == transaction.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTransaction(_ transaction: TransactionModel) {
|
||||||
|
if let index = transactions.firstIndex(where: { $0.id == transaction.id }) {
|
||||||
|
transactions[index] = transaction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTransaction(withId id: String) -> TransactionModel? {
|
||||||
|
transactions.first { $0.id == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
60
Coinly/Features/Transactions/TransactionsSummaryView.swift
Normal file
60
Coinly/Features/Transactions/TransactionsSummaryView.swift
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
//
|
||||||
|
// TransactionsSummaryView.swift
|
||||||
|
// Coinly
|
||||||
|
//
|
||||||
|
// Created by Vadym Samoilenko on 03/03/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TransactionsSummaryView: View {
|
||||||
|
let transactions: [TransactionModel]
|
||||||
|
@EnvironmentObject private var settings: AppSettings
|
||||||
|
|
||||||
|
private var income: Double {
|
||||||
|
transactions
|
||||||
|
.filter { $0.type == .income }
|
||||||
|
.reduce(0) { $0 + $1.amount }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var expenses: Double {
|
||||||
|
transactions
|
||||||
|
.filter { $0.type == .expense }
|
||||||
|
.reduce(0) { $0 + $1.amount }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Income")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(income.formatted(.currency(code: settings.selectedCurrency.rawValue)))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
Text("Expenses")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(expenses.formatted(.currency(code: settings.selectedCurrency.rawValue)))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.textCase(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
TransactionsSummaryView(transactions: TransactionModel.previewItems)
|
||||||
|
.environmentObject(AppSettings.shared)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
145
Coinly/Features/Transactions/TransactionsView.swift
Normal file
145
Coinly/Features/Transactions/TransactionsView.swift
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TransactionsView: View {
|
||||||
|
@EnvironmentObject private var transactionsStore: TransactionsStore
|
||||||
|
@EnvironmentObject private var accountsStore: AccountsStore
|
||||||
|
@EnvironmentObject private var categoryStore: CategoryStore
|
||||||
|
@State private var showingAddTransaction = false
|
||||||
|
@State private var showingFilter = false
|
||||||
|
@State private var currentFilter = TransactionFilter()
|
||||||
|
|
||||||
|
private var filteredTransactions: [TransactionModel] {
|
||||||
|
transactionsStore.filterTransactions(filter: currentFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if !filteredTransactions.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(filteredTransactions) { transaction in
|
||||||
|
TransactionRowView(transaction: transaction)
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
deleteTransaction(transaction)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Income")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(totalIncome.formatted(.currency(code: "USD")))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
Text("Expenses")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(totalExpenses.formatted(.currency(code: "USD")))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Balance")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(balance.formatted(.currency(code: "USD")))
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textCase(nil)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Transactions")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showingAddTransaction = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button {
|
||||||
|
showingFilter = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingAddTransaction) {
|
||||||
|
NavigationView {
|
||||||
|
AddTransactionView { transaction in
|
||||||
|
transactionsStore.addTransaction(transaction)
|
||||||
|
if let account = accountsStore.getAccount(withId: transaction.accountId ?? "") {
|
||||||
|
let newBalance = account.balance + (transaction.type == .income ? transaction.amount : -transaction.amount)
|
||||||
|
accountsStore.updateAccount(account.with(balance: newBalance))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingFilter) {
|
||||||
|
NavigationView {
|
||||||
|
TransactionFilterView(filter: $currentFilter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if filteredTransactions.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Transactions",
|
||||||
|
systemImage: "arrow.left.arrow.right",
|
||||||
|
description: Text("Add your first transaction to start tracking your finances")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalIncome: Double {
|
||||||
|
filteredTransactions
|
||||||
|
.filter { $0.type == .income }
|
||||||
|
.reduce(0) { $0 + $1.amount }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalExpenses: Double {
|
||||||
|
filteredTransactions
|
||||||
|
.filter { $0.type == .expense }
|
||||||
|
.reduce(0) { $0 + $1.amount }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var balance: Double {
|
||||||
|
totalIncome - totalExpenses
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteTransaction(_ transaction: TransactionModel) {
|
||||||
|
if let account = accountsStore.getAccount(withId: transaction.accountId ?? "") {
|
||||||
|
let amount = transaction.type == .income ? -transaction.amount : transaction.amount
|
||||||
|
let newBalance = account.balance + amount
|
||||||
|
accountsStore.updateAccount(account.with(balance: newBalance))
|
||||||
|
}
|
||||||
|
transactionsStore.deleteTransaction(transaction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
TransactionsView()
|
||||||
|
.environmentObject(TransactionsStore.shared)
|
||||||
|
.environmentObject(AccountsStore.shared)
|
||||||
|
.environmentObject(CategoryStore.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
26
Coinly/UI/Components/CategoryIconView.swift
Normal file
26
Coinly/UI/Components/CategoryIconView.swift
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
//
|
||||||
|
// CategoryIconView.swift
|
||||||
|
// Coinly
|
||||||
|
//
|
||||||
|
// Created by Vadym Samoilenko on 03/03/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CategoryIconView: View {
|
||||||
|
let category: CategoryModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Image(systemName: category.icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(category.iconColor())
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
CategoryIconView(category: CategoryModel.sampleData)
|
||||||
|
}
|
||||||
63
Coinly/UI/Components/CategoryPickerView.swift
Normal file
63
Coinly/UI/Components/CategoryPickerView.swift
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CategoryPickerView: View {
|
||||||
|
@EnvironmentObject private var categoryStore: CategoryStore
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var searchText = ""
|
||||||
|
|
||||||
|
let transactionType: TransactionType
|
||||||
|
let onSelect: (CategoryModel) -> Void
|
||||||
|
|
||||||
|
private var filteredCategories: [CategoryModel] {
|
||||||
|
let categories = categoryStore.getCategories(of: transactionType)
|
||||||
|
if searchText.isEmpty {
|
||||||
|
return categories
|
||||||
|
}
|
||||||
|
return categories.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(filteredCategories) { category in
|
||||||
|
Button {
|
||||||
|
onSelect(category)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
CategoryRowView(category: category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Select Category")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.searchable(text: $searchText, prompt: "Search categories")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
NavigationLink {
|
||||||
|
AddCategoryView(type: transactionType) { category in
|
||||||
|
categoryStore.addCategory(category)
|
||||||
|
onSelect(category)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
CategoryPickerView(
|
||||||
|
transactionType: .expense,
|
||||||
|
onSelect: { _ in }
|
||||||
|
)
|
||||||
|
.environmentObject(CategoryStore.shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
Coinly/UI/Components/CategoryRowView.swift
Normal file
40
Coinly/UI/Components/CategoryRowView.swift
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CategoryRowView: View {
|
||||||
|
let category: CategoryModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Image(systemName: category.icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(category.iconColor())
|
||||||
|
.cornerRadius(8)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(category.name)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if category.isDefault {
|
||||||
|
Text("Default")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
CategoryRowView(category: CategoryModel(
|
||||||
|
name: "Food",
|
||||||
|
icon: "cart.fill",
|
||||||
|
color: "systemRed",
|
||||||
|
type: .expense,
|
||||||
|
isDefault: true
|
||||||
|
))
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
112
Coinly/UI/Components/ChartView.swift
Normal file
112
Coinly/UI/Components/ChartView.swift
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
//
|
||||||
|
// ChartView.swift
|
||||||
|
// Coinly
|
||||||
|
//
|
||||||
|
// Created by Vadym Samoilenko on 02/03/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ChartView: View {
|
||||||
|
let data: [(String, Double)]
|
||||||
|
let accentColor: Color
|
||||||
|
|
||||||
|
private var total: Double {
|
||||||
|
data.reduce(0) { $0 + $1.1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Pie Chart
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let diameter = min(geometry.size.width, geometry.size.height)
|
||||||
|
ZStack {
|
||||||
|
ForEach(data.indices, id: \.self) { index in
|
||||||
|
PieSlice(
|
||||||
|
startAngle: startAngle(for: index),
|
||||||
|
endAngle: endAngle(for: index),
|
||||||
|
color: accentColor.opacity(opacity(for: index))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
}
|
||||||
|
.aspectRatio(1, contentMode: .fit)
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(data, id: \.0) { item in
|
||||||
|
HStack {
|
||||||
|
Circle()
|
||||||
|
.fill(accentColor.opacity(opacity(for: data.firstIndex(where: { $0.0 == item.0 }) ?? 0)))
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
|
||||||
|
Text(item.0)
|
||||||
|
.font(.subheadline)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(item.1.formatAsCurrency())
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startAngle(for index: Int) -> Double {
|
||||||
|
let prior = data.prefix(index).reduce(0) { $0 + $1.1 }
|
||||||
|
return (prior / total) * 360
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endAngle(for index: Int) -> Double {
|
||||||
|
let prior = data.prefix(index + 1).reduce(0) { $0 + $1.1 }
|
||||||
|
return (prior / total) * 360
|
||||||
|
}
|
||||||
|
|
||||||
|
private func opacity(for index: Int) -> Double {
|
||||||
|
let count = Double(data.count)
|
||||||
|
return 1.0 - (Double(index) * 0.5 / count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PieSlice: View {
|
||||||
|
let startAngle: Double
|
||||||
|
let endAngle: Double
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Path { path in
|
||||||
|
path.move(to: .zero)
|
||||||
|
path.addArc(
|
||||||
|
center: .zero,
|
||||||
|
radius: 1,
|
||||||
|
startAngle: .degrees(-90 + startAngle),
|
||||||
|
endAngle: .degrees(-90 + endAngle),
|
||||||
|
clockwise: false
|
||||||
|
)
|
||||||
|
path.closeSubpath()
|
||||||
|
}
|
||||||
|
.fill(color)
|
||||||
|
.scaleEffect(CGSize(width: 1, height: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChartView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
ChartView(
|
||||||
|
data: [
|
||||||
|
("Food", 250),
|
||||||
|
("Transport", 150),
|
||||||
|
("Entertainment", 100),
|
||||||
|
("Shopping", 200)
|
||||||
|
],
|
||||||
|
accentColor: .blue
|
||||||
|
)
|
||||||
|
.padding()
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue