diff --git a/Coinly/.DS_Store b/Coinly/.DS_Store new file mode 100644 index 0000000..823813e Binary files /dev/null and b/Coinly/.DS_Store differ diff --git a/Coinly/App/CoinlyApp.swift b/Coinly/App/CoinlyApp.swift deleted file mode 100644 index 6323cd1..0000000 --- a/Coinly/App/CoinlyApp.swift +++ /dev/null @@ -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) - } - } -} diff --git a/Coinly/App/ContentView.swift b/Coinly/App/ContentView.swift deleted file mode 100644 index 38c6a70..0000000 --- a/Coinly/App/ContentView.swift +++ /dev/null @@ -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() -} diff --git a/Coinly/App/Persistence.swift b/Coinly/App/Persistence.swift deleted file mode 100644 index 092704b..0000000 --- a/Coinly/App/Persistence.swift +++ /dev/null @@ -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 - } -} diff --git a/Coinly/Coinly.xcodeproj/project.pbxproj b/Coinly/Coinly.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6b766a8 --- /dev/null +++ b/Coinly/Coinly.xcodeproj/project.pbxproj @@ -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 = ""; + }; + F75D6AD82D75CB060073F403 /* CoinlyTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CoinlyTests; + sourceTree = ""; + }; + F75D6AE22D75CB060073F403 /* CoinlyUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CoinlyUITests; + sourceTree = ""; + }; +/* 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 = ""; + }; + F75D6AC12D75CB050073F403 /* Products */ = { + isa = PBXGroup; + children = ( + F75D6AC02D75CB050073F403 /* Coinly.app */, + F75D6AD52D75CB060073F403 /* CoinlyTests.xctest */, + F75D6ADF2D75CB060073F403 /* CoinlyUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/Coinly/Coinly.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Coinly/Coinly.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Coinly/Coinly.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Coinly/Coinly.xcodeproj/project.xcworkspace/xcuserdata/vadymsamoilenko.xcuserdatad/UserInterfaceState.xcuserstate b/Coinly/Coinly.xcodeproj/project.xcworkspace/xcuserdata/vadymsamoilenko.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..62271ff Binary files /dev/null and b/Coinly/Coinly.xcodeproj/project.xcworkspace/xcuserdata/vadymsamoilenko.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Coinly/Coinly.xcodeproj/xcuserdata/vadymsamoilenko.xcuserdatad/xcschemes/xcschememanagement.plist b/Coinly/Coinly.xcodeproj/xcuserdata/vadymsamoilenko.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..f3e1503 --- /dev/null +++ b/Coinly/Coinly.xcodeproj/xcuserdata/vadymsamoilenko.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Coinly.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/Coinly/Coinly/App/CoinlyApp.swift b/Coinly/Coinly/App/CoinlyApp.swift new file mode 100644 index 0000000..574d930 --- /dev/null +++ b/Coinly/Coinly/App/CoinlyApp.swift @@ -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) + } + } +} diff --git a/Coinly/Assets.xcassets/AccentColor.colorset/Contents.json b/Coinly/Coinly/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Coinly/Assets.xcassets/AccentColor.colorset/Contents.json rename to Coinly/Coinly/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Coinly/Assets.xcassets/AppIcon.appiconset/Contents.json b/Coinly/Coinly/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from Coinly/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Coinly/Coinly/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/Coinly/Assets.xcassets/Contents.json b/Coinly/Coinly/Assets.xcassets/Contents.json similarity index 100% rename from Coinly/Assets.xcassets/Contents.json rename to Coinly/Coinly/Assets.xcassets/Contents.json diff --git a/Coinly/Coinly/Core/Data/CoreData/Coinly.xcdatamodeld/.xccurrentversion b/Coinly/Coinly/Core/Data/CoreData/Coinly.xcdatamodeld/.xccurrentversion new file mode 100644 index 0000000..a48ce7d --- /dev/null +++ b/Coinly/Coinly/Core/Data/CoreData/Coinly.xcdatamodeld/.xccurrentversion @@ -0,0 +1,8 @@ + + + + + _XCCurrentVersionName + Coinly.xcdatamodel + + diff --git a/Coinly/Coinly/Core/Data/CoreData/Coinly.xcdatamodeld/Coinly.xcdatamodel/contents b/Coinly/Coinly/Core/Data/CoreData/Coinly.xcdatamodeld/Coinly.xcdatamodel/contents new file mode 100644 index 0000000..9ed2921 --- /dev/null +++ b/Coinly/Coinly/Core/Data/CoreData/Coinly.xcdatamodeld/Coinly.xcdatamodel/contents @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/Coinly/Coinly/Core/Data/CoreData/Persistence.swift b/Coinly/Coinly/Core/Data/CoreData/Persistence.swift new file mode 100644 index 0000000..c6fb682 --- /dev/null +++ b/Coinly/Coinly/Core/Data/CoreData/Persistence.swift @@ -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 + } +} diff --git a/Coinly/Coinly/Core/Domain/Models/Account.swift b/Coinly/Coinly/Core/Domain/Models/Account.swift new file mode 100644 index 0000000..c160904 --- /dev/null +++ b/Coinly/Coinly/Core/Domain/Models/Account.swift @@ -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 diff --git a/Coinly/Coinly/Core/Domain/Models/AccountType.swift b/Coinly/Coinly/Core/Domain/Models/AccountType.swift new file mode 100644 index 0000000..88bb3d9 --- /dev/null +++ b/Coinly/Coinly/Core/Domain/Models/AccountType.swift @@ -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 diff --git a/Coinly/Coinly/Core/Domain/Models/Currency.swift b/Coinly/Coinly/Core/Domain/Models/Currency.swift new file mode 100644 index 0000000..8963cfc --- /dev/null +++ b/Coinly/Coinly/Core/Domain/Models/Currency.swift @@ -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 diff --git a/Coinly/Coinly/Features/Accounts/ViewModels/AccountViewModel.swift b/Coinly/Coinly/Features/Accounts/ViewModels/AccountViewModel.swift new file mode 100644 index 0000000..399afb6 --- /dev/null +++ b/Coinly/Coinly/Features/Accounts/ViewModels/AccountViewModel.swift @@ -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 \ No newline at end of file diff --git a/Coinly/Coinly/Features/Accounts/Views/AccountRowView.swift b/Coinly/Coinly/Features/Accounts/Views/AccountRowView.swift new file mode 100644 index 0000000..4b81edb --- /dev/null +++ b/Coinly/Coinly/Features/Accounts/Views/AccountRowView.swift @@ -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 \ No newline at end of file diff --git a/Coinly/Coinly/Features/Accounts/Views/ContentView.swift b/Coinly/Coinly/Features/Accounts/Views/ContentView.swift new file mode 100644 index 0000000..5cd5c55 --- /dev/null +++ b/Coinly/Coinly/Features/Accounts/Views/ContentView.swift @@ -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 + + 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) +} diff --git a/Coinly/Preview Content/Preview Assets.xcassets/Contents.json b/Coinly/Coinly/Preview Content/Preview Assets.xcassets/Contents.json similarity index 100% rename from Coinly/Preview Content/Preview Assets.xcassets/Contents.json rename to Coinly/Coinly/Preview Content/Preview Assets.xcassets/Contents.json diff --git a/Coinly/CoinlyTests/CoinlyTests.swift b/Coinly/CoinlyTests/CoinlyTests.swift new file mode 100644 index 0000000..c29b975 --- /dev/null +++ b/Coinly/CoinlyTests/CoinlyTests.swift @@ -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. + } + } + +} diff --git a/Coinly/CoinlyUITests/CoinlyUITests.swift b/Coinly/CoinlyUITests/CoinlyUITests.swift new file mode 100644 index 0000000..e1f7651 --- /dev/null +++ b/Coinly/CoinlyUITests/CoinlyUITests.swift @@ -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() + } + } + } +} diff --git a/Coinly/CoinlyUITests/CoinlyUITestsLaunchTests.swift b/Coinly/CoinlyUITests/CoinlyUITestsLaunchTests.swift new file mode 100644 index 0000000..02e6fc9 --- /dev/null +++ b/Coinly/CoinlyUITests/CoinlyUITestsLaunchTests.swift @@ -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) + } +} diff --git a/Coinly/Core/Style/AppStyle.swift b/Coinly/Core/Style/AppStyle.swift deleted file mode 100644 index c2c3bce..0000000 --- a/Coinly/Core/Style/AppStyle.swift +++ /dev/null @@ -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)) - } -} diff --git a/Coinly/Core/Utils/AppCurrencyFormatter.swift b/Coinly/Core/Utils/AppCurrencyFormatter.swift deleted file mode 100644 index 193ae0d..0000000 --- a/Coinly/Core/Utils/AppCurrencyFormatter.swift +++ /dev/null @@ -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) - } -} diff --git a/Coinly/Extensions/Color+Extensions.swift b/Coinly/Extensions/Color+Extensions.swift deleted file mode 100644 index 8e3ec0c..0000000 --- a/Coinly/Extensions/Color+Extensions.swift +++ /dev/null @@ -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) - } - } -} diff --git a/Coinly/Features/Accounts/AccountCardView.swift b/Coinly/Features/Accounts/AccountCardView.swift deleted file mode 100644 index 566c7fd..0000000 --- a/Coinly/Features/Accounts/AccountCardView.swift +++ /dev/null @@ -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() -} diff --git a/Coinly/Features/Accounts/AccountDetailView.swift b/Coinly/Features/Accounts/AccountDetailView.swift deleted file mode 100644 index ea6e906..0000000 --- a/Coinly/Features/Accounts/AccountDetailView.swift +++ /dev/null @@ -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) - } -} diff --git a/Coinly/Features/Accounts/AccountPickerView.swift b/Coinly/Features/Accounts/AccountPickerView.swift deleted file mode 100644 index 75ea6df..0000000 --- a/Coinly/Features/Accounts/AccountPickerView.swift +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/Coinly/Features/Accounts/AccountRowView.swift b/Coinly/Features/Accounts/AccountRowView.swift deleted file mode 100644 index 7f91e4f..0000000 --- a/Coinly/Features/Accounts/AccountRowView.swift +++ /dev/null @@ -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() -} diff --git a/Coinly/Features/Accounts/AccountsListView.swift b/Coinly/Features/Accounts/AccountsListView.swift deleted file mode 100644 index b560aac..0000000 --- a/Coinly/Features/Accounts/AccountsListView.swift +++ /dev/null @@ -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) - } -} diff --git a/Coinly/Features/Accounts/AccountsSummaryView.swift b/Coinly/Features/Accounts/AccountsSummaryView.swift deleted file mode 100644 index bb7f490..0000000 --- a/Coinly/Features/Accounts/AccountsSummaryView.swift +++ /dev/null @@ -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)) -} diff --git a/Coinly/Features/Accounts/AddAccountView.swift b/Coinly/Features/Accounts/AddAccountView.swift deleted file mode 100644 index a8d03e5..0000000 --- a/Coinly/Features/Accounts/AddAccountView.swift +++ /dev/null @@ -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) - } -} diff --git a/Coinly/Features/Accounts/EditAccountView.swift b/Coinly/Features/Accounts/EditAccountView.swift deleted file mode 100644 index 696c0cf..0000000 --- a/Coinly/Features/Accounts/EditAccountView.swift +++ /dev/null @@ -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) - } -} diff --git a/Coinly/Features/Accounts/SelectAccountTypeView.swift b/Coinly/Features/Accounts/SelectAccountTypeView.swift deleted file mode 100644 index 85dbef2..0000000 --- a/Coinly/Features/Accounts/SelectAccountTypeView.swift +++ /dev/null @@ -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)") - } - } -} diff --git a/Coinly/Features/Budget/Models/BudgetModel.swift b/Coinly/Features/Budget/Models/BudgetModel.swift deleted file mode 100644 index 5f27301..0000000 --- a/Coinly/Features/Budget/Models/BudgetModel.swift +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/Coinly/Features/Budget/Views/BudgetProgressView.swift b/Coinly/Features/Budget/Views/BudgetProgressView.swift deleted file mode 100644 index 46ff1df..0000000 --- a/Coinly/Features/Budget/Views/BudgetProgressView.swift +++ /dev/null @@ -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() -} \ No newline at end of file diff --git a/Coinly/Features/Budget/Views/BudgetSettingsView.swift b/Coinly/Features/Budget/Views/BudgetSettingsView.swift deleted file mode 100644 index 027a7a6..0000000 --- a/Coinly/Features/Budget/Views/BudgetSettingsView.swift +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/Coinly/Features/Budget/Views/CategoryBudgetEditView.swift b/Coinly/Features/Budget/Views/CategoryBudgetEditView.swift deleted file mode 100644 index efbffbd..0000000 --- a/Coinly/Features/Budget/Views/CategoryBudgetEditView.swift +++ /dev/null @@ -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) - } -} diff --git a/Coinly/Features/Budget/Views/CategoryBudgetRowView.swift b/Coinly/Features/Budget/Views/CategoryBudgetRowView.swift deleted file mode 100644 index f9533de..0000000 --- a/Coinly/Features/Budget/Views/CategoryBudgetRowView.swift +++ /dev/null @@ -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() -} diff --git a/Coinly/Features/Budget/Views/CategoryBudgetSettingsView.swift b/Coinly/Features/Budget/Views/CategoryBudgetSettingsView.swift deleted file mode 100644 index f0fe248..0000000 --- a/Coinly/Features/Budget/Views/CategoryBudgetSettingsView.swift +++ /dev/null @@ -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) - } -} diff --git a/Coinly/Features/Budget/Views/MonthlyBudgetSettingsView.swift b/Coinly/Features/Budget/Views/MonthlyBudgetSettingsView.swift deleted file mode 100644 index c70d12c..0000000 --- a/Coinly/Features/Budget/Views/MonthlyBudgetSettingsView.swift +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/Coinly/Features/Categories/AddCategoryView.swift b/Coinly/Features/Categories/AddCategoryView.swift deleted file mode 100644 index dd6c0e0..0000000 --- a/Coinly/Features/Categories/AddCategoryView.swift +++ /dev/null @@ -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) - } - } - } -} diff --git a/Coinly/Features/Categories/CategoryListView.swift b/Coinly/Features/Categories/CategoryListView.swift deleted file mode 100644 index 4df1719..0000000 --- a/Coinly/Features/Categories/CategoryListView.swift +++ /dev/null @@ -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) - } -} diff --git a/Coinly/Features/Dashboard/DashboardView.swift b/Coinly/Features/Dashboard/DashboardView.swift deleted file mode 100644 index 6fe1dbd..0000000 --- a/Coinly/Features/Dashboard/DashboardView.swift +++ /dev/null @@ -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) - } -} diff --git a/Coinly/Features/Models/AccountModel.swift b/Coinly/Features/Models/AccountModel.swift deleted file mode 100644 index 86709ab..0000000 --- a/Coinly/Features/Models/AccountModel.swift +++ /dev/null @@ -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 - ) - ] -} diff --git a/Coinly/Features/Models/AccountType.swift b/Coinly/Features/Models/AccountType.swift deleted file mode 100644 index 2721ec4..0000000 --- a/Coinly/Features/Models/AccountType.swift +++ /dev/null @@ -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 - } - } -} diff --git a/Coinly/Features/Models/AccountsStore.swift b/Coinly/Features/Models/AccountsStore.swift deleted file mode 100644 index 7c0b47e..0000000 --- a/Coinly/Features/Models/AccountsStore.swift +++ /dev/null @@ -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 } - } -} diff --git a/Coinly/Features/Models/AppSettings.swift b/Coinly/Features/Models/AppSettings.swift deleted file mode 100644 index 6bc5f04..0000000 --- a/Coinly/Features/Models/AppSettings.swift +++ /dev/null @@ -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 "£" - } - } - } -} diff --git a/Coinly/Features/Models/CategoryModel.swift b/Coinly/Features/Models/CategoryModel.swift deleted file mode 100644 index 03c03f9..0000000 --- a/Coinly/Features/Models/CategoryModel.swift +++ /dev/null @@ -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 - ) - ] -} diff --git a/Coinly/Features/Models/CategoryStore.swift b/Coinly/Features/Models/CategoryStore.swift deleted file mode 100644 index 8536f7c..0000000 --- a/Coinly/Features/Models/CategoryStore.swift +++ /dev/null @@ -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 - } -} diff --git a/Coinly/Features/Models/DebtPayment.swift b/Coinly/Features/Models/DebtPayment.swift deleted file mode 100644 index b186acd..0000000 --- a/Coinly/Features/Models/DebtPayment.swift +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/Coinly/Features/Models/TransactionFilter.swift b/Coinly/Features/Models/TransactionFilter.swift deleted file mode 100644 index d714dd0..0000000 --- a/Coinly/Features/Models/TransactionFilter.swift +++ /dev/null @@ -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) - } - } - } -} diff --git a/Coinly/Features/Models/TransactionModel.swift b/Coinly/Features/Models/TransactionModel.swift deleted file mode 100644 index 68192fb..0000000 --- a/Coinly/Features/Models/TransactionModel.swift +++ /dev/null @@ -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 - ) - ] -} diff --git a/Coinly/Features/Models/TransactionType.swift b/Coinly/Features/Models/TransactionType.swift deleted file mode 100644 index 9f97ad9..0000000 --- a/Coinly/Features/Models/TransactionType.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -enum TransactionType: String, Codable, CaseIterable, Hashable { - case expense = "Expense" - case income = "Income" - - var description: String { - self.rawValue - } -} diff --git a/Coinly/Features/Profile/ProfileView.swift b/Coinly/Features/Profile/ProfileView.swift deleted file mode 100644 index 813b59a..0000000 --- a/Coinly/Features/Profile/ProfileView.swift +++ /dev/null @@ -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) - } -} diff --git a/Coinly/Features/Transactions/AddTransactionView.swift b/Coinly/Features/Transactions/AddTransactionView.swift deleted file mode 100644 index 9a2538f..0000000 --- a/Coinly/Features/Transactions/AddTransactionView.swift +++ /dev/null @@ -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) - } -} diff --git a/Coinly/Features/Transactions/TransactionFilterView.swift b/Coinly/Features/Transactions/TransactionFilterView.swift deleted file mode 100644 index 4128503..0000000 --- a/Coinly/Features/Transactions/TransactionFilterView.swift +++ /dev/null @@ -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) { - 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.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.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) - } -} diff --git a/Coinly/Features/Transactions/TransactionRowView.swift b/Coinly/Features/Transactions/TransactionRowView.swift deleted file mode 100644 index d2c4140..0000000 --- a/Coinly/Features/Transactions/TransactionRowView.swift +++ /dev/null @@ -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() -} diff --git a/Coinly/Features/Transactions/TransactionsStore.swift b/Coinly/Features/Transactions/TransactionsStore.swift deleted file mode 100644 index fedc076..0000000 --- a/Coinly/Features/Transactions/TransactionsStore.swift +++ /dev/null @@ -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 } - } -} diff --git a/Coinly/Features/Transactions/TransactionsSummaryView.swift b/Coinly/Features/Transactions/TransactionsSummaryView.swift deleted file mode 100644 index 0b78c3c..0000000 --- a/Coinly/Features/Transactions/TransactionsSummaryView.swift +++ /dev/null @@ -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() -} diff --git a/Coinly/Features/Transactions/TransactionsView.swift b/Coinly/Features/Transactions/TransactionsView.swift deleted file mode 100644 index e9de905..0000000 --- a/Coinly/Features/Transactions/TransactionsView.swift +++ /dev/null @@ -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) - } -} diff --git a/Coinly/UI/Components/CategoryIconView.swift b/Coinly/UI/Components/CategoryIconView.swift deleted file mode 100644 index f21bff0..0000000 --- a/Coinly/UI/Components/CategoryIconView.swift +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/Coinly/UI/Components/CategoryPickerView.swift b/Coinly/UI/Components/CategoryPickerView.swift deleted file mode 100644 index 508bb59..0000000 --- a/Coinly/UI/Components/CategoryPickerView.swift +++ /dev/null @@ -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) - } -} diff --git a/Coinly/UI/Components/CategoryRowView.swift b/Coinly/UI/Components/CategoryRowView.swift deleted file mode 100644 index 0a52ce5..0000000 --- a/Coinly/UI/Components/CategoryRowView.swift +++ /dev/null @@ -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() -} diff --git a/Coinly/UI/Components/ChartView.swift b/Coinly/UI/Components/ChartView.swift deleted file mode 100644 index 0ffd569..0000000 --- a/Coinly/UI/Components/ChartView.swift +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/Coinly/UI/Components/CurrencyField.swift b/Coinly/UI/Components/CurrencyField.swift deleted file mode 100644 index 87abed3..0000000 --- a/Coinly/UI/Components/CurrencyField.swift +++ /dev/null @@ -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, 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) - } -} \ No newline at end of file diff --git a/Coinly/UI/Components/EmptyTransactionsView.swift b/Coinly/UI/Components/EmptyTransactionsView.swift deleted file mode 100644 index 48163f0..0000000 --- a/Coinly/UI/Components/EmptyTransactionsView.swift +++ /dev/null @@ -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) - } - } -} \ No newline at end of file