v2: Add core models (Currency, AccountType, Account) and basic UI components (AccountRowView)
This commit is contained in:
parent
d957385a17
commit
f77b5f149e
70 changed files with 1570 additions and 2906 deletions
BIN
Coinly/.DS_Store
vendored
Normal file
BIN
Coinly/.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -1,19 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
563
Coinly/Coinly.xcodeproj/project.pbxproj
Normal file
563
Coinly/Coinly.xcodeproj/project.pbxproj
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* 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; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
F75D6AC22D75CB050073F403 /* Coinly */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
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 = (
|
||||
);
|
||||
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 */,
|
||||
F75D6AC12D75CB050073F403 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F75D6AC12D75CB050073F403 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F75D6AC02D75CB050073F403 /* Coinly.app */,
|
||||
F75D6AD52D75CB060073F403 /* CoinlyTests.xctest */,
|
||||
F75D6ADF2D75CB060073F403 /* CoinlyUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
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_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Coinly/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = DQDRL8F7U2;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
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_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Coinly/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = DQDRL8F7U2;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
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 */;
|
||||
}
|
||||
7
Coinly/Coinly.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Coinly/Coinly.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
Binary file not shown.
|
|
@ -0,0 +1,14 @@
|
|||
<?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>
|
||||
20
Coinly/Coinly/App/CoinlyApp.swift
Normal file
20
Coinly/Coinly/App/CoinlyApp.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// CoinlyApp.swift
|
||||
// Coinly
|
||||
//
|
||||
// Created by Vadym Samoilenko on 03/03/2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CoinlyApp: App {
|
||||
let persistenceController = PersistenceController.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?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>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="1" systemVersion="11A491" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="false" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Item" representedClassName="Item" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Item" positionX="-63" positionY="-18" width="128" height="44"/>
|
||||
</elements>
|
||||
</model>
|
||||
57
Coinly/Coinly/Core/Data/CoreData/Persistence.swift
Normal file
57
Coinly/Coinly/Core/Data/CoreData/Persistence.swift
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//
|
||||
// Persistence.swift
|
||||
// Coinly
|
||||
//
|
||||
// Created by Vadym Samoilenko on 03/03/2025.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
|
||||
struct PersistenceController {
|
||||
static let shared = PersistenceController()
|
||||
|
||||
@MainActor
|
||||
static let preview: PersistenceController = {
|
||||
let result = PersistenceController(inMemory: true)
|
||||
let viewContext = result.container.viewContext
|
||||
for _ in 0..<10 {
|
||||
let newItem = Item(context: viewContext)
|
||||
newItem.timestamp = Date()
|
||||
}
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
let nsError = error as NSError
|
||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
let container: NSPersistentContainer
|
||||
|
||||
init(inMemory: Bool = false) {
|
||||
container = NSPersistentContainer(name: "Coinly")
|
||||
if inMemory {
|
||||
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
|
||||
}
|
||||
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
|
||||
if let error = error as NSError? {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
|
||||
/*
|
||||
Typical reasons for an error here include:
|
||||
* The parent directory does not exist, cannot be created, or disallows writing.
|
||||
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
|
||||
* The device is out of space.
|
||||
* The store could not be migrated to the current model version.
|
||||
Check the error message to determine what the actual problem was.
|
||||
*/
|
||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
}
|
||||
})
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
}
|
||||
}
|
||||
181
Coinly/Coinly/Core/Domain/Models/Account.swift
Normal file
181
Coinly/Coinly/Core/Domain/Models/Account.swift
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
//
|
||||
// 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: .checking,
|
||||
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
|
||||
137
Coinly/Coinly/Core/Domain/Models/AccountType.swift
Normal file
137
Coinly/Coinly/Core/Domain/Models/AccountType.swift
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import Foundation
|
||||
|
||||
enum AccountType: String, CaseIterable, Identifiable, Codable {
|
||||
// MARK: - Cases
|
||||
case cash = "Cash" // Наличные
|
||||
case checking = "Checking" // Текущий счет
|
||||
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 .checking: return "building.columns"
|
||||
case .savings: return "sparkles"
|
||||
case .investmentAccount: return "chart.line.uptrend.xyaxis"
|
||||
case .credit: return "creditcard"
|
||||
case .loan: return "handshake"
|
||||
case .deposit: return "safe"
|
||||
case .debtToMe: return "arrow.left.circle"
|
||||
case .myDebt: return "arrow.right.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var color: String {
|
||||
switch self {
|
||||
case .cash: return "green"
|
||||
case .checking: 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, .deposit:
|
||||
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, .checking, .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 .checking:
|
||||
return NSLocalizedString("Everyday banking account", comment: "Checking 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 {
|
||||
.checking
|
||||
}
|
||||
}
|
||||
#endif
|
||||
117
Coinly/Coinly/Core/Domain/Models/Currency.swift
Normal file
117
Coinly/Coinly/Core/Domain/Models/Currency.swift
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
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 {
|
||||
if let currencyCode = locale.currency?.identifier,
|
||||
let currency = all.first(where: { $0.code == currencyCode }) {
|
||||
return currency
|
||||
}
|
||||
return defaultCurrency
|
||||
}
|
||||
|
||||
/// Британский фунт как валюта по умолчанию
|
||||
static var defaultCurrency: Currency {
|
||||
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 }) else { return nil }
|
||||
|
||||
let name = locale.localizedString(forCurrencyCode: currency.identifier) ?? currency.identifier
|
||||
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] {
|
||||
[
|
||||
defaultCurrency,
|
||||
Currency(code: "USD", numericCode: "840", name: "US Dollar", symbol: "$", fractionDigits: 2, minorUnit: 100),
|
||||
Currency(code: "EUR", numericCode: "978", name: "Euro", symbol: "€", fractionDigits: 2, minorUnit: 100),
|
||||
Currency(code: "UAH", numericCode: "980", name: "Hryvnia", symbol: "₴", fractionDigits: 2, minorUnit: 100),
|
||||
Currency(code: "JPY", numericCode: "392", name: "Yen", symbol: "¥", fractionDigits: 0, minorUnit: 1)
|
||||
]
|
||||
}
|
||||
|
||||
static var preview: Currency {
|
||||
defaultCurrency
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
//
|
||||
// AccountViewModel.swift
|
||||
// Coinly
|
||||
//
|
||||
// Created by Vadym Samoilenko on 03/03/2025.
|
||||
//
|
||||
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class AccountViewModel: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
@Published private(set) var accounts: [Account] = []
|
||||
@Published private(set) var isLoading = false
|
||||
@Published private(set) var error: Error?
|
||||
|
||||
// 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: - Total Balance Calculations
|
||||
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 }
|
||||
}
|
||||
|
||||
// MARK: - Account Operations
|
||||
func addAccount(_ account: Account) async throws {
|
||||
// TODO: Implement repository call
|
||||
accounts.append(account)
|
||||
}
|
||||
|
||||
func updateAccount(_ account: Account) async throws {
|
||||
guard let index = accounts.firstIndex(where: { $0.id == account.id }) else {
|
||||
throw AccountError.accountNotFound
|
||||
}
|
||||
accounts[index] = account
|
||||
// TODO: Implement repository call
|
||||
}
|
||||
|
||||
func deleteAccount(_ account: Account) async throws {
|
||||
accounts.removeAll { $0.id == account.id }
|
||||
// TODO: Implement repository call
|
||||
}
|
||||
|
||||
func archiveAccount(_ account: Account) async throws {
|
||||
var updatedAccount = account
|
||||
updatedAccount.archive()
|
||||
try await updateAccount(updatedAccount)
|
||||
}
|
||||
|
||||
func unarchiveAccount(_ account: Account) async throws {
|
||||
var updatedAccount = account
|
||||
updatedAccount.unarchive()
|
||||
try await updateAccount(updatedAccount)
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
func loadAccounts() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
do {
|
||||
// TODO: Implement repository call
|
||||
// Temporary using sample data
|
||||
accounts = Account.sampleData
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
// MARK: - Filtering
|
||||
func accounts(for type: AccountType) -> [Account] {
|
||||
activeAccounts.filter { $0.type == type }
|
||||
}
|
||||
|
||||
func accounts(for currency: Currency) -> [Account] {
|
||||
activeAccounts.filter { $0.currency == currency }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
enum AccountError: LocalizedError {
|
||||
case accountNotFound
|
||||
case invalidAmount
|
||||
case insufficientFunds
|
||||
case exceedsCreditLimit
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .accountNotFound:
|
||||
return NSLocalizedString("Account not found", comment: "")
|
||||
case .invalidAmount:
|
||||
return NSLocalizedString("Invalid amount", comment: "")
|
||||
case .insufficientFunds:
|
||||
return NSLocalizedString("Insufficient funds", comment: "")
|
||||
case .exceedsCreditLimit:
|
||||
return NSLocalizedString("Exceeds credit limit", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview Helper
|
||||
#if DEBUG
|
||||
extension AccountViewModel {
|
||||
static var preview: AccountViewModel {
|
||||
let viewModel = AccountViewModel()
|
||||
viewModel.accounts = Account.sampleData
|
||||
return viewModel
|
||||
}
|
||||
}
|
||||
#endif
|
||||
119
Coinly/Coinly/Features/Accounts/Views/AccountRowView.swift
Normal file
119
Coinly/Coinly/Features/Accounts/Views/AccountRowView.swift
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
//
|
||||
// AccountRowView.swift
|
||||
// Coinly
|
||||
//
|
||||
// Created by Vadym Samoilenko on 03/03/2025.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AccountRowView: View {
|
||||
let account: Account
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
// Icon
|
||||
Image(systemName: account.icon ?? account.type.icon)
|
||||
.font(.title2)
|
||||
.foregroundColor(Color(account.type.color))
|
||||
.frame(width: 32)
|
||||
|
||||
// Account Info
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(account.name)
|
||||
.font(.body)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
HStack {
|
||||
Text(account.type.localizedName)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if account.type.requiresInterestRate,
|
||||
let interestRate = account.interestRate {
|
||||
Text("•")
|
||||
.foregroundColor(.secondary)
|
||||
Text(formatInterestRate(interestRate))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Balance
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text(account.formattedBalance)
|
||||
.font(.body)
|
||||
.foregroundColor(balanceColor)
|
||||
|
||||
if let creditLimit = account.creditLimit {
|
||||
Text(formatAvailableCredit(creditLimit))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 16)
|
||||
.background(Color(UIColor.systemBackground))
|
||||
.opacity(account.isArchived ? 0.6 : 1.0)
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
private var balanceColor: Color {
|
||||
if account.isOverdrawn {
|
||||
return .red
|
||||
}
|
||||
return account.balance >= 0 ? .primary : .red
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
private func formatInterestRate(_ rate: Decimal) -> String {
|
||||
let percentage = rate * 100
|
||||
return String(format: "%.1f%%", NSDecimalNumber(decimal: percentage).doubleValue)
|
||||
}
|
||||
|
||||
private func formatAvailableCredit(_ limit: Decimal) -> String {
|
||||
let available = limit - account.balance
|
||||
return String(
|
||||
format: NSLocalizedString("Available: %@", comment: "Available credit"),
|
||||
account.currency.format(available)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#if DEBUG
|
||||
struct AccountRowView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
// Regular account
|
||||
AccountRowView(account: Account.sampleData[0])
|
||||
|
||||
// Credit card
|
||||
AccountRowView(account: Account.sampleData[3])
|
||||
|
||||
// Archived account
|
||||
AccountRowView(account: Account(
|
||||
name: "Archived Account",
|
||||
type: .savings,
|
||||
currency: .preview,
|
||||
balance: 1000,
|
||||
isArchived: true
|
||||
))
|
||||
|
||||
// Overdrawn account
|
||||
AccountRowView(account: Account(
|
||||
name: "Overdrawn Account",
|
||||
type: .checking,
|
||||
currency: .preview,
|
||||
balance: -500
|
||||
))
|
||||
}
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
86
Coinly/Coinly/Features/Accounts/Views/ContentView.swift
Normal file
86
Coinly/Coinly/Features/Accounts/Views/ContentView.swift
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
//
|
||||
// ContentView.swift
|
||||
// Coinly
|
||||
//
|
||||
// Created by Vadym Samoilenko on 03/03/2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
|
||||
animation: .default)
|
||||
private var items: FetchedResults<Item>
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
NavigationLink {
|
||||
Text("Item at \(item.timestamp!, formatter: itemFormatter)")
|
||||
} label: {
|
||||
Text(item.timestamp!, formatter: itemFormatter)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteItems)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
EditButton()
|
||||
}
|
||||
ToolbarItem {
|
||||
Button(action: addItem) {
|
||||
Label("Add Item", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("Select an item")
|
||||
}
|
||||
}
|
||||
|
||||
private func addItem() {
|
||||
withAnimation {
|
||||
let newItem = Item(context: viewContext)
|
||||
newItem.timestamp = Date()
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
let nsError = error as NSError
|
||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteItems(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
offsets.map { items[$0] }.forEach(viewContext.delete)
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
let nsError = error as NSError
|
||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let itemFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .medium
|
||||
return formatter
|
||||
}()
|
||||
|
||||
#Preview {
|
||||
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
|
||||
}
|
||||
36
Coinly/CoinlyTests/CoinlyTests.swift
Normal file
36
Coinly/CoinlyTests/CoinlyTests.swift
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
//
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
43
Coinly/CoinlyUITests/CoinlyUITests.swift
Normal file
43
Coinly/CoinlyUITests/CoinlyUITests.swift
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
Coinly/CoinlyUITests/CoinlyUITestsLaunchTests.swift
Normal file
33
Coinly/CoinlyUITests/CoinlyUITestsLaunchTests.swift
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
//
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
struct AppCurrencyFormatter {
|
||||
static func format(_ amount: Double, currency: AppSettings.Currency = AppSettings.shared.selectedCurrency) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = currency.rawValue
|
||||
formatter.minimumFractionDigits = 2
|
||||
formatter.maximumFractionDigits = 2
|
||||
|
||||
return formatter.string(from: NSNumber(value: amount)) ?? "\(currency.symbol)\(amount)"
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем расширение для Double для удобного форматирования
|
||||
extension Double {
|
||||
func formatAsCurrency(currency: AppSettings.Currency = AppSettings.shared.selectedCurrency) -> String {
|
||||
AppCurrencyFormatter.format(self, currency: currency)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
// 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))
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
// 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
//
|
||||
// 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()
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
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
|
||||
)
|
||||
]
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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 "£"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
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
|
||||
)
|
||||
]
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
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
|
||||
)
|
||||
]
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
enum TransactionType: String, Codable, CaseIterable, Hashable {
|
||||
case expense = "Expense"
|
||||
case income = "Income"
|
||||
|
||||
var description: String {
|
||||
self.rawValue
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
// 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()
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
//
|
||||
// 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()
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
//
|
||||
// CategoryIconView.swift
|
||||
// Coinly
|
||||
//
|
||||
// Created by Vadym Samoilenko on 03/03/2025.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CategoryIconView: View {
|
||||
let category: CategoryModel
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: category.icon)
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(category.iconColor())
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CategoryIconView(category: CategoryModel.sampleData)
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
//
|
||||
// CurrencyField.swift
|
||||
// Coinly
|
||||
//
|
||||
// Created by Vadym Samoilenko on 03/03/2025.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CurrencyField: View {
|
||||
let title: String
|
||||
@Binding var value: Double
|
||||
let currency: AppSettings.Currency
|
||||
|
||||
init(_ title: String, value: Binding<Double>, currency: AppSettings.Currency) {
|
||||
self.title = title
|
||||
self._value = value
|
||||
self.currency = currency
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
TextField("0.00", value: $value, format: .currency(code: currency.rawValue))
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
Form {
|
||||
CurrencyField("Amount", value: .constant(100), currency: .usd)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
//
|
||||
// EmptyTransactionsView.swift
|
||||
// Coinly
|
||||
//
|
||||
// Created by Vadym Samoilenko on 02/03/2025.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct EmptyTransactionsView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "creditcard")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(Color(uiColor: .systemGray))
|
||||
|
||||
Text("No Transactions")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("Start adding your transactions to track your expenses and income")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color(uiColor: .secondaryLabel))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyTransactionsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
EmptyTransactionsView()
|
||||
.preferredColorScheme(.light)
|
||||
|
||||
EmptyTransactionsView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue