diff --git a/InvestmentTracker.xcodeproj/project.pbxproj b/InvestmentTracker.xcodeproj/project.pbxproj index 96d7f76..f3cc724 100644 --- a/InvestmentTracker.xcodeproj/project.pbxproj +++ b/InvestmentTracker.xcodeproj/project.pbxproj @@ -6,15 +6,51 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 0E241ECF2F0DAA3C00283E2F /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */; }; + 0E241ED12F0DAA3C00283E2F /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */; }; + 0E241EE22F0DAA3E00283E2F /* InvestmentTrackerWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 0E241EE02F0DAA3E00283E2F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0E241E312F0DA93A00283E2F /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0E241ECB2F0DAA3C00283E2F; + remoteInfo = InvestmentTrackerWidgetExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 0E241EE72F0DAA3E00283E2F /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 0E241EE22F0DAA3E00283E2F /* InvestmentTrackerWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 0E241E392F0DA93A00283E2F /* InvestmentTracker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = InvestmentTracker.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = InvestmentTrackerWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 0E241EED2F0DAC7D00283E2F /* InvestmentTrackerWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = InvestmentTrackerWidgetExtension.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 0E241E492F0DA93C00283E2F /* Exceptions for "InvestmentTracker" folder in "InvestmentTracker" target */ = { + 0E8318942F0DB2FB0030C2F9 /* Exceptions for "InvestmentTracker" folder in "InvestmentTracker" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Info.plist, + Assets.xcassets, + Resources/Info.plist, + Resources/Localizable.strings, ); target = 0E241E382F0DA93A00283E2F /* InvestmentTracker */; }; @@ -24,11 +60,16 @@ 0E241E3B2F0DA93A00283E2F /* InvestmentTracker */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( - 0E241E492F0DA93C00283E2F /* Exceptions for "InvestmentTracker" folder in "InvestmentTracker" target */, + 0E8318942F0DB2FB0030C2F9 /* Exceptions for "InvestmentTracker" folder in "InvestmentTracker" target */, ); path = InvestmentTracker; sourceTree = ""; }; + 0E241ED22F0DAA3C00283E2F /* InvestmentTrackerWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = InvestmentTrackerWidget; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -39,13 +80,25 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 0E241EC92F0DAA3C00283E2F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0E241ED12F0DAA3C00283E2F /* SwiftUI.framework in Frameworks */, + 0E241ECF2F0DAA3C00283E2F /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 0E241E302F0DA93A00283E2F = { isa = PBXGroup; children = ( + 0E241EED2F0DAC7D00283E2F /* InvestmentTrackerWidgetExtension.entitlements */, 0E241E3B2F0DA93A00283E2F /* InvestmentTracker */, + 0E241ED22F0DAA3C00283E2F /* InvestmentTrackerWidget */, + 0E241ECD2F0DAA3C00283E2F /* Frameworks */, 0E241E3A2F0DA93A00283E2F /* Products */, ); sourceTree = ""; @@ -54,10 +107,20 @@ isa = PBXGroup; children = ( 0E241E392F0DA93A00283E2F /* InvestmentTracker.app */, + 0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */, ); name = Products; sourceTree = ""; }; + 0E241ECD2F0DAA3C00283E2F /* Frameworks */ = { + isa = PBXGroup; + children = ( + 0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */, + 0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -67,11 +130,13 @@ buildPhases = ( 0E241E352F0DA93A00283E2F /* Sources */, 0E241E362F0DA93A00283E2F /* Frameworks */, - 0E241E372F0DA93A00283E2F /* Resources */, + 0E241EE72F0DAA3E00283E2F /* Embed Foundation Extensions */, + 0E8318932F0DB2FB0030C2F9 /* Resources */, ); buildRules = ( ); dependencies = ( + 0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 0E241E3B2F0DA93A00283E2F /* InvestmentTracker */, @@ -83,6 +148,28 @@ productReference = 0E241E392F0DA93A00283E2F /* InvestmentTracker.app */; productType = "com.apple.product-type.application"; }; + 0E241ECB2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0E241EE32F0DAA3E00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTrackerWidgetExtension" */; + buildPhases = ( + 0E241EC82F0DAA3C00283E2F /* Sources */, + 0E241EC92F0DAA3C00283E2F /* Frameworks */, + 0E241ECA2F0DAA3C00283E2F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 0E241ED22F0DAA3C00283E2F /* InvestmentTrackerWidget */, + ); + name = InvestmentTrackerWidgetExtension; + packageProductDependencies = ( + ); + productName = InvestmentTrackerWidgetExtension; + productReference = 0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -96,6 +183,9 @@ 0E241E382F0DA93A00283E2F = { CreatedOnToolsVersion = 26.2; }; + 0E241ECB2F0DAA3C00283E2F = { + CreatedOnToolsVersion = 26.2; + }; }; }; buildConfigurationList = 0E241E342F0DA93A00283E2F /* Build configuration list for PBXProject "InvestmentTracker" */; @@ -107,18 +197,30 @@ ); mainGroup = 0E241E302F0DA93A00283E2F; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + 0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 0E241E3A2F0DA93A00283E2F /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 0E241E382F0DA93A00283E2F /* InvestmentTracker */, + 0E241ECB2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 0E241E372F0DA93A00283E2F /* Resources */ = { + 0E241ECA2F0DAA3C00283E2F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0E8318932F0DB2FB0030C2F9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -135,8 +237,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 0E241EC82F0DAA3C00283E2F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0E241ECB2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension */; + targetProxy = 0E241EE02F0DAA3E00283E2F /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 0E241E4B2F0DA93C00283E2F /* Debug */ = { isa = XCBuildConfiguration; @@ -146,6 +263,7 @@ CODE_SIGN_ENTITLEMENTS = InvestmentTracker/InvestmentTracker.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = InvestmentTracker/Info.plist; @@ -179,6 +297,7 @@ CODE_SIGN_ENTITLEMENTS = InvestmentTracker/InvestmentTracker.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = InvestmentTracker/Info.plist; @@ -323,6 +442,66 @@ }; name = Release; }; + 0E241EE42F0DAA3E00283E2F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = InvestmentTrackerWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ""; + INFOPLIST_KEY_CFBundleDisplayName = InvestmentTrackerWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.InvestmentTracker.InvestmentTrackerWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 0E241EE52F0DAA3E00283E2F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = InvestmentTrackerWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ""; + INFOPLIST_KEY_CFBundleDisplayName = InvestmentTrackerWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.InvestmentTracker.InvestmentTrackerWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -344,7 +523,35 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 0E241EE32F0DAA3E00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTrackerWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0E241EE42F0DAA3E00283E2F /* Debug */, + 0E241EE52F0DAA3E00283E2F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 12.7.0; + }; + }; + 0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/googleads/swift-package-manager-google-mobile-ads"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 12.14.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ }; rootObject = 0E241E312F0DA93A00283E2F /* Project object */; } diff --git a/InvestmentTracker/InvestmentTracker.xcdatamodeld/.xccurrentversion b/InvestmentTracker.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 64% rename from InvestmentTracker/InvestmentTracker.xcdatamodeld/.xccurrentversion rename to InvestmentTracker.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings index 8c4897d..0c67376 100644 --- a/InvestmentTracker/InvestmentTracker.xcdatamodeld/.xccurrentversion +++ b/InvestmentTracker.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -1,8 +1,5 @@ - - _XCCurrentVersionName - InvestmentTracker.xcdatamodel - + diff --git a/InvestmentTracker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/InvestmentTracker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..6879033 --- /dev/null +++ b/InvestmentTracker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,150 @@ +{ + "originHash" : "2d3f06aab1ccdfff7b100ca10208a834bfb664b3c50890610f1e3c349eb3fbf3", + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "45210bd1ea695779e6de016ab00fea8c0b7eb2ef", + "version" : "12.7.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "35b601a60fbbea2de3ea461f604deaaa4d8bbd0c", + "version" : "3.2.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "c2d59acf17a8ba7ed80a763593c67c9c7c006ad1", + "version" : "12.5.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "fb7f2740b1570d2f7599c6bb9531bf4fad6974b7", + "version" : "5.0.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, + { + "identity" : "swift-package-manager-google-mobile-ads", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/swift-package-manager-google-mobile-ads", + "state" : { + "revision" : "1239c23464503053c17d37ee5daa70de3455e9c8", + "version" : "12.14.0" + } + }, + { + "identity" : "swift-package-manager-google-user-messaging-platform", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/swift-package-manager-google-user-messaging-platform.git", + "state" : { + "revision" : "13b248eaa73b7826f0efb1bcf455e251d65ecb1b", + "version" : "3.1.0" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "c169a5744230951031770e27e475ff6eefe51f9d", + "version" : "1.33.3" + } + } + ], + "version" : 3 +} diff --git a/InvestmentTracker.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/WorkspaceSettings.xcsettings b/InvestmentTracker.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..723a561 --- /dev/null +++ b/InvestmentTracker.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,16 @@ + + + + + BuildLocationStyle + UseAppPreferences + CompilationCachingSetting + Default + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/InvestmentTracker.xcodeproj/xcshareddata/xcschemes/InvestmentTracker.xcscheme b/InvestmentTracker.xcodeproj/xcshareddata/xcschemes/InvestmentTracker.xcscheme new file mode 100644 index 0000000..0894426 --- /dev/null +++ b/InvestmentTracker.xcodeproj/xcshareddata/xcschemes/InvestmentTracker.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/InvestmentTracker.xcodeproj/xcshareddata/xcschemes/InvestmentTrackerWidgetExtension.xcscheme b/InvestmentTracker.xcodeproj/xcshareddata/xcschemes/InvestmentTrackerWidgetExtension.xcscheme new file mode 100644 index 0000000..f253391 --- /dev/null +++ b/InvestmentTracker.xcodeproj/xcshareddata/xcschemes/InvestmentTrackerWidgetExtension.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/InvestmentTracker.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist b/InvestmentTracker.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist index 25284e0..2412e25 100644 --- a/InvestmentTracker.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/InvestmentTracker.xcodeproj/xcuserdata/alexandrev.xcuserdatad/xcschemes/xcschememanagement.plist @@ -5,10 +5,33 @@ SchemeUserState InvestmentTracker.xcscheme_^#shared#^_ + + orderHint + 1 + + InvestmentTrackerWidgetExtension.xcscheme_^#shared#^_ orderHint 0 + Promises (Playground).xcscheme + + orderHint + 2 + + + SuppressBuildableAutocreation + + 0E241E382F0DA93A00283E2F + + primary + + + 0E241ECB2F0DAA3C00283E2F + + primary + + diff --git a/InvestmentTracker/App/AppDelegate.swift b/InvestmentTracker/App/AppDelegate.swift new file mode 100644 index 0000000..13d522f --- /dev/null +++ b/InvestmentTracker/App/AppDelegate.swift @@ -0,0 +1,47 @@ +import UIKit +import UserNotifications + +class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + // Request notification permissions + requestNotificationPermissions() + + // Register background tasks + registerBackgroundTasks() + + return true + } + + private func requestNotificationPermissions() { + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + if let error = error { + print("Notification permission error: \(error)") + } + print("Notification permission granted: \(granted)") + } + } + + private func registerBackgroundTasks() { + // Background fetch for updating widget data and notification badges + UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum) + } + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() + print("Device token: \(token)") + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + print("Failed to register for remote notifications: \(error)") + } +} diff --git a/InvestmentTracker/App/ContentView.swift b/InvestmentTracker/App/ContentView.swift new file mode 100644 index 0000000..264ddbf --- /dev/null +++ b/InvestmentTracker/App/ContentView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var iapService: IAPService + @EnvironmentObject var adMobService: AdMobService + @AppStorage("onboardingCompleted") private var onboardingCompleted = false + + @State private var selectedTab = 0 + + var body: some View { + Group { + if !onboardingCompleted { + OnboardingView(onboardingCompleted: $onboardingCompleted) + } else { + mainContent + } + } + } + + private var mainContent: some View { + ZStack(alignment: .bottom) { + TabView(selection: $selectedTab) { + DashboardView() + .tabItem { + Label("Dashboard", systemImage: "chart.pie.fill") + } + .tag(0) + + SourceListView() + .tabItem { + Label("Sources", systemImage: "list.bullet") + } + .tag(1) + + ChartsContainerView() + .tabItem { + Label("Charts", systemImage: "chart.xyaxis.line") + } + .tag(2) + + SettingsView() + .tabItem { + Label("Settings", systemImage: "gearshape.fill") + } + .tag(3) + } + + // Banner ad at bottom for free users + if !iapService.isPremium { + VStack(spacing: 0) { + Spacer() + BannerAdView() + .frame(height: 50) + } + .padding(.bottom, 49) // Account for tab bar height + } + } + } +} + +#Preview { + ContentView() + .environmentObject(IAPService()) + .environmentObject(AdMobService()) +} diff --git a/InvestmentTracker/App/InvestmentTrackerApp.swift b/InvestmentTracker/App/InvestmentTrackerApp.swift new file mode 100644 index 0000000..90246d5 --- /dev/null +++ b/InvestmentTracker/App/InvestmentTrackerApp.swift @@ -0,0 +1,20 @@ +import SwiftUI +import FirebaseCore + +@main +struct InvestmentTrackerApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @StateObject private var iapService = IAPService() + @StateObject private var adMobService = AdMobService() + + let coreDataStack = CoreDataStack.shared + + var body: some Scene { + WindowGroup { + ContentView() + .environment(\.managedObjectContext, coreDataStack.viewContext) + .environmentObject(iapService) + .environmentObject(adMobService) + } + } +} diff --git a/InvestmentTracker/ContentView.swift b/InvestmentTracker/ContentView.swift deleted file mode 100644 index d87fb1d..0000000 --- a/InvestmentTracker/ContentView.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// ContentView.swift -// InvestmentTracker -// -// Created by Alexandre Vazquez on 06/01/2026. -// - -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/InvestmentTracker/Info.plist b/InvestmentTracker/Info.plist deleted file mode 100644 index ca9a074..0000000 --- a/InvestmentTracker/Info.plist +++ /dev/null @@ -1,10 +0,0 @@ - - - - - UIBackgroundModes - - remote-notification - - - diff --git a/InvestmentTracker/InvestmentTracker.entitlements b/InvestmentTracker/InvestmentTracker.entitlements index 9e0940e..e45c380 100644 --- a/InvestmentTracker/InvestmentTracker.entitlements +++ b/InvestmentTracker/InvestmentTracker.entitlements @@ -5,10 +5,16 @@ aps-environment development com.apple.developer.icloud-container-identifiers - + + iCloud.com.yourteam.investmenttracker + com.apple.developer.icloud-services CloudKit + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)$(CFBundleIdentifier) + com.apple.security.application-groups + diff --git a/InvestmentTracker/InvestmentTracker.xcdatamodeld/InvestmentTracker.xcdatamodel/contents b/InvestmentTracker/InvestmentTracker.xcdatamodeld/InvestmentTracker.xcdatamodel/contents deleted file mode 100644 index e8d6ec8..0000000 --- a/InvestmentTracker/InvestmentTracker.xcdatamodeld/InvestmentTracker.xcdatamodel/contents +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/InvestmentTracker/InvestmentTrackerApp.swift b/InvestmentTracker/InvestmentTrackerApp.swift deleted file mode 100644 index 9abc160..0000000 --- a/InvestmentTracker/InvestmentTrackerApp.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// InvestmentTrackerApp.swift -// InvestmentTracker -// -// Created by Alexandre Vazquez on 06/01/2026. -// - -import SwiftUI -import CoreData - -@main -struct InvestmentTrackerApp: App { - let persistenceController = PersistenceController.shared - - var body: some Scene { - WindowGroup { - ContentView() - .environment(\.managedObjectContext, persistenceController.container.viewContext) - } - } -} diff --git a/InvestmentTracker/Models/CoreData/AppSettings+CoreDataClass.swift b/InvestmentTracker/Models/CoreData/AppSettings+CoreDataClass.swift new file mode 100644 index 0000000..6be2078 --- /dev/null +++ b/InvestmentTracker/Models/CoreData/AppSettings+CoreDataClass.swift @@ -0,0 +1,71 @@ +import Foundation +import CoreData + +@objc(AppSettings) +public class AppSettings: NSManagedObject, Identifiable { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "AppSettings") + } + + @NSManaged public var id: UUID + @NSManaged public var currency: String + @NSManaged public var defaultNotificationTime: Date? + @NSManaged public var enableAnalytics: Bool + @NSManaged public var onboardingCompleted: Bool + @NSManaged public var lastSyncDate: Date? + @NSManaged public var createdAt: Date + + public override func awakeFromInsert() { + super.awakeFromInsert() + id = UUID() + currency = "EUR" + enableAnalytics = true + onboardingCompleted = false + createdAt = Date() + + // Default notification time: 9:00 AM + var components = DateComponents() + components.hour = 9 + components.minute = 0 + defaultNotificationTime = Calendar.current.date(from: components) + } +} + +// MARK: - Computed Properties + +extension AppSettings { + var currencySymbol: String { + let locale = Locale.current + return locale.currencySymbol ?? "€" + } + + var notificationTimeString: String { + guard let time = defaultNotificationTime else { return "9:00 AM" } + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter.string(from: time) + } +} + +// MARK: - Static Helpers + +extension AppSettings { + static func getOrCreate(in context: NSManagedObjectContext) -> AppSettings { + let request: NSFetchRequest = AppSettings.fetchRequest() + request.fetchLimit = 1 + + if let existing = try? context.fetch(request).first { + return existing + } + + let new = AppSettings(context: context) + try? context.save() + return new + } + + static func markOnboardingComplete(in context: NSManagedObjectContext) { + let settings = getOrCreate(in: context) + settings.onboardingCompleted = true + try? context.save() + } +} diff --git a/InvestmentTracker/Models/CoreData/Category+CoreDataClass.swift b/InvestmentTracker/Models/CoreData/Category+CoreDataClass.swift new file mode 100644 index 0000000..22f1483 --- /dev/null +++ b/InvestmentTracker/Models/CoreData/Category+CoreDataClass.swift @@ -0,0 +1,92 @@ +import Foundation +import CoreData +import SwiftUI + +@objc(Category) +public class Category: NSManagedObject, Identifiable { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "Category") + } + + @NSManaged public var id: UUID + @NSManaged public var name: String + @NSManaged public var colorHex: String + @NSManaged public var icon: String + @NSManaged public var sortOrder: Int16 + @NSManaged public var createdAt: Date + @NSManaged public var sources: NSSet? + + public override func awakeFromInsert() { + super.awakeFromInsert() + id = UUID() + createdAt = Date() + sortOrder = 0 + colorHex = "#3B82F6" + icon = "chart.pie.fill" + name = "" + } +} + +// MARK: - Computed Properties + +extension Category { + var color: Color { + Color(hex: colorHex) ?? .blue + } + + var sourcesArray: [InvestmentSource] { + let set = sources as? Set ?? [] + return set.sorted { $0.name < $1.name } + } + + var sourceCount: Int { + sources?.count ?? 0 + } + + var totalValue: Decimal { + sourcesArray.reduce(Decimal.zero) { $0 + $1.latestValue } + } +} + +// MARK: - Generated accessors for sources + +extension Category { + @objc(addSourcesObject:) + @NSManaged public func addToSources(_ value: InvestmentSource) + + @objc(removeSourcesObject:) + @NSManaged public func removeFromSources(_ value: InvestmentSource) + + @objc(addSources:) + @NSManaged public func addToSources(_ values: NSSet) + + @objc(removeSources:) + @NSManaged public func removeFromSources(_ values: NSSet) +} + +// MARK: - Default Categories + +extension Category { + static let defaultCategories: [(name: String, colorHex: String, icon: String)] = [ + ("Stocks", "#10B981", "chart.line.uptrend.xyaxis"), + ("Bonds", "#3B82F6", "building.columns.fill"), + ("Real Estate", "#F59E0B", "house.fill"), + ("Crypto", "#8B5CF6", "bitcoinsign.circle.fill"), + ("Cash", "#6B7280", "banknote.fill"), + ("ETFs", "#EC4899", "chart.bar.fill"), + ("Retirement", "#14B8A6", "person.fill"), + ("Other", "#64748B", "ellipsis.circle.fill") + ] + + static func createDefaultCategories(in context: NSManagedObjectContext) { + for (index, categoryData) in defaultCategories.enumerated() { + let category = Category(context: context) + category.name = categoryData.name + category.colorHex = categoryData.colorHex + category.icon = categoryData.icon + category.sortOrder = Int16(index) + } + + try? context.save() + } +} diff --git a/InvestmentTracker/Models/CoreData/InvestmentSource+CoreDataClass.swift b/InvestmentTracker/Models/CoreData/InvestmentSource+CoreDataClass.swift new file mode 100644 index 0000000..6eccf54 --- /dev/null +++ b/InvestmentTracker/Models/CoreData/InvestmentSource+CoreDataClass.swift @@ -0,0 +1,144 @@ +import Foundation +import CoreData + +@objc(InvestmentSource) +public class InvestmentSource: NSManagedObject, Identifiable { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "InvestmentSource") + } + + @NSManaged public var id: UUID + @NSManaged public var name: String + @NSManaged public var notificationFrequency: String + @NSManaged public var customFrequencyMonths: Int16 + @NSManaged public var isActive: Bool + @NSManaged public var createdAt: Date + @NSManaged public var category: Category? + @NSManaged public var snapshots: NSSet? + + public override func awakeFromInsert() { + super.awakeFromInsert() + id = UUID() + createdAt = Date() + isActive = true + notificationFrequency = NotificationFrequency.monthly.rawValue + customFrequencyMonths = 1 + name = "" + } +} + +// MARK: - Notification Frequency + +enum NotificationFrequency: String, CaseIterable, Identifiable { + case monthly = "monthly" + case quarterly = "quarterly" + case semiannual = "semiannual" + case annual = "annual" + case custom = "custom" + case never = "never" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .monthly: return "Monthly" + case .quarterly: return "Quarterly" + case .semiannual: return "Semi-Annual" + case .annual: return "Annual" + case .custom: return "Custom" + case .never: return "Never" + } + } + + var months: Int { + switch self { + case .monthly: return 1 + case .quarterly: return 3 + case .semiannual: return 6 + case .annual: return 12 + case .custom, .never: return 0 + } + } +} + +// MARK: - Computed Properties + +extension InvestmentSource { + var snapshotsArray: [Snapshot] { + let set = snapshots as? Set ?? [] + return set.sorted { $0.date > $1.date } + } + + var sortedSnapshotsByDateAscending: [Snapshot] { + let set = snapshots as? Set ?? [] + return set.sorted { $0.date < $1.date } + } + + var latestSnapshot: Snapshot? { + snapshotsArray.first + } + + var latestValue: Decimal { + latestSnapshot?.value?.decimalValue ?? Decimal.zero + } + + var snapshotCount: Int { + snapshots?.count ?? 0 + } + + var frequency: NotificationFrequency { + NotificationFrequency(rawValue: notificationFrequency) ?? .monthly + } + + var nextReminderDate: Date? { + guard frequency != .never else { return nil } + + let months = frequency == .custom ? Int(customFrequencyMonths) : frequency.months + guard let lastSnapshot = latestSnapshot else { + return Date() // Remind now if no snapshots + } + + return Calendar.current.date(byAdding: .month, value: months, to: lastSnapshot.date) + } + + var needsUpdate: Bool { + guard let nextDate = nextReminderDate else { return false } + return Date() >= nextDate + } + + // MARK: - Performance Metrics + + var totalReturn: Decimal { + guard let first = sortedSnapshotsByDateAscending.first, + let last = snapshotsArray.first, + let firstValue = first.value?.decimalValue, + firstValue != Decimal.zero else { + return Decimal.zero + } + + let lastValue = last.value?.decimalValue ?? Decimal.zero + return ((lastValue - firstValue) / firstValue) * 100 + } + + var totalContributions: Decimal { + snapshotsArray.reduce(Decimal.zero) { result, snapshot in + result + (snapshot.contribution?.decimalValue ?? Decimal.zero) + } + } +} + +// MARK: - Generated accessors for snapshots + +extension InvestmentSource { + @objc(addSnapshotsObject:) + @NSManaged public func addToSnapshots(_ value: Snapshot) + + @objc(removeSnapshotsObject:) + @NSManaged public func removeFromSnapshots(_ value: Snapshot) + + @objc(addSnapshots:) + @NSManaged public func addToSnapshots(_ values: NSSet) + + @objc(removeSnapshots:) + @NSManaged public func removeFromSnapshots(_ values: NSSet) +} diff --git a/InvestmentTracker/Models/CoreData/InvestmentTracker.xcdatamodeld/InvestmentTracker.xcdatamodel/contents b/InvestmentTracker/Models/CoreData/InvestmentTracker.xcdatamodeld/InvestmentTracker.xcdatamodel/contents new file mode 100644 index 0000000..f4c9feb --- /dev/null +++ b/InvestmentTracker/Models/CoreData/InvestmentTracker.xcdatamodeld/InvestmentTracker.xcdatamodel/contents @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/InvestmentTracker/Models/CoreData/PredictionCache+CoreDataClass.swift b/InvestmentTracker/Models/CoreData/PredictionCache+CoreDataClass.swift new file mode 100644 index 0000000..25db4af --- /dev/null +++ b/InvestmentTracker/Models/CoreData/PredictionCache+CoreDataClass.swift @@ -0,0 +1,147 @@ +import Foundation +import CoreData + +@objc(PredictionCache) +public class PredictionCache: NSManagedObject, Identifiable { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "PredictionCache") + } + + @NSManaged public var id: UUID + @NSManaged public var sourceId: UUID? + @NSManaged public var algorithm: String + @NSManaged public var predictionData: Data? + @NSManaged public var calculatedAt: Date + @NSManaged public var validUntil: Date + + public override func awakeFromInsert() { + super.awakeFromInsert() + id = UUID() + calculatedAt = Date() + // Cache valid for 24 hours + validUntil = Calendar.current.date(byAdding: .hour, value: 24, to: Date()) ?? Date() + algorithm = PredictionAlgorithm.linear.rawValue + } +} + +// MARK: - Prediction Algorithm + +enum PredictionAlgorithm: String, CaseIterable, Identifiable { + case linear = "linear" + case exponentialSmoothing = "exponential_smoothing" + case movingAverage = "moving_average" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .linear: return "Linear Regression" + case .exponentialSmoothing: return "Exponential Smoothing" + case .movingAverage: return "Moving Average" + } + } + + var description: String { + switch self { + case .linear: + return "Projects future values based on historical trend line" + case .exponentialSmoothing: + return "Gives more weight to recent data points" + case .movingAverage: + return "Smooths out short-term fluctuations" + } + } +} + +// MARK: - Computed Properties + +extension PredictionCache { + var isValid: Bool { + Date() < validUntil + } + + var algorithmType: PredictionAlgorithm { + PredictionAlgorithm(rawValue: algorithm) ?? .linear + } + + var predictions: [Prediction]? { + guard let data = predictionData else { return nil } + return try? JSONDecoder().decode([Prediction].self, from: data) + } +} + +// MARK: - Static Helpers + +extension PredictionCache { + static func getCache( + for sourceId: UUID?, + algorithm: PredictionAlgorithm, + in context: NSManagedObjectContext + ) -> PredictionCache? { + let request: NSFetchRequest = PredictionCache.fetchRequest() + + if let sourceId = sourceId { + request.predicate = NSPredicate( + format: "sourceId == %@ AND algorithm == %@", + sourceId as CVarArg, + algorithm.rawValue + ) + } else { + request.predicate = NSPredicate( + format: "sourceId == nil AND algorithm == %@", + algorithm.rawValue + ) + } + + request.fetchLimit = 1 + + guard let cache = try? context.fetch(request).first, + cache.isValid else { + return nil + } + + return cache + } + + static func saveCache( + for sourceId: UUID?, + algorithm: PredictionAlgorithm, + predictions: [Prediction], + in context: NSManagedObjectContext + ) { + // Remove old cache + let request: NSFetchRequest = PredictionCache.fetchRequest() + if let sourceId = sourceId { + request.predicate = NSPredicate( + format: "sourceId == %@ AND algorithm == %@", + sourceId as CVarArg, + algorithm.rawValue + ) + } else { + request.predicate = NSPredicate( + format: "sourceId == nil AND algorithm == %@", + algorithm.rawValue + ) + } + + if let oldCache = try? context.fetch(request) { + oldCache.forEach { context.delete($0) } + } + + // Create new cache + let cache = PredictionCache(context: context) + cache.sourceId = sourceId + cache.algorithm = algorithm.rawValue + cache.predictionData = try? JSONEncoder().encode(predictions) + + try? context.save() + } + + static func invalidateAll(in context: NSManagedObjectContext) { + let request: NSFetchRequest = PredictionCache.fetchRequest() + if let caches = try? context.fetch(request) { + caches.forEach { context.delete($0) } + } + try? context.save() + } +} diff --git a/InvestmentTracker/Models/CoreData/PremiumStatus+CoreDataClass.swift b/InvestmentTracker/Models/CoreData/PremiumStatus+CoreDataClass.swift new file mode 100644 index 0000000..61dcbdd --- /dev/null +++ b/InvestmentTracker/Models/CoreData/PremiumStatus+CoreDataClass.swift @@ -0,0 +1,82 @@ +import Foundation +import CoreData + +@objc(PremiumStatus) +public class PremiumStatus: NSManagedObject, Identifiable { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "PremiumStatus") + } + + @NSManaged public var id: UUID + @NSManaged public var isPremium: Bool + @NSManaged public var purchaseDate: Date? + @NSManaged public var productIdentifier: String? + @NSManaged public var transactionId: String? + @NSManaged public var isFamilyShared: Bool + @NSManaged public var lastVerificationDate: Date? + @NSManaged public var originalPurchaseDate: Date? + + public override func awakeFromInsert() { + super.awakeFromInsert() + id = UUID() + isPremium = false + isFamilyShared = false + } +} + +// MARK: - Computed Properties + +extension PremiumStatus { + var daysSincePurchase: Int? { + guard let purchaseDate = purchaseDate else { return nil } + return Calendar.current.dateComponents([.day], from: purchaseDate, to: Date()).day + } + + var needsVerification: Bool { + guard let lastVerification = lastVerificationDate else { return true } + let hoursSinceVerification = Calendar.current.dateComponents( + [.hour], + from: lastVerification, + to: Date() + ).hour ?? 0 + return hoursSinceVerification >= 24 + } +} + +// MARK: - Static Helpers + +extension PremiumStatus { + static func getOrCreate(in context: NSManagedObjectContext) -> PremiumStatus { + let request: NSFetchRequest = PremiumStatus.fetchRequest() + request.fetchLimit = 1 + + if let existing = try? context.fetch(request).first { + return existing + } + + let new = PremiumStatus(context: context) + try? context.save() + return new + } + + static func updateStatus( + isPremium: Bool, + productIdentifier: String?, + transactionId: String?, + isFamilyShared: Bool, + in context: NSManagedObjectContext + ) { + let status = getOrCreate(in: context) + status.isPremium = isPremium + status.productIdentifier = productIdentifier + status.transactionId = transactionId + status.isFamilyShared = isFamilyShared + status.lastVerificationDate = Date() + + if isPremium && status.purchaseDate == nil { + status.purchaseDate = Date() + } + + try? context.save() + } +} diff --git a/InvestmentTracker/Models/CoreData/Snapshot+CoreDataClass.swift b/InvestmentTracker/Models/CoreData/Snapshot+CoreDataClass.swift new file mode 100644 index 0000000..031f32c --- /dev/null +++ b/InvestmentTracker/Models/CoreData/Snapshot+CoreDataClass.swift @@ -0,0 +1,83 @@ +import Foundation +import CoreData + +@objc(Snapshot) +public class Snapshot: NSManagedObject, Identifiable { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "Snapshot") + } + + @NSManaged public var id: UUID + @NSManaged public var date: Date + @NSManaged public var value: NSDecimalNumber? + @NSManaged public var contribution: NSDecimalNumber? + @NSManaged public var notes: String? + @NSManaged public var createdAt: Date + @NSManaged public var source: InvestmentSource? + + public override func awakeFromInsert() { + super.awakeFromInsert() + id = UUID() + date = Date() + createdAt = Date() + } +} + +// MARK: - Computed Properties + +extension Snapshot { + var decimalValue: Decimal { + value?.decimalValue ?? Decimal.zero + } + + var decimalContribution: Decimal { + contribution?.decimalValue ?? Decimal.zero + } + + var formattedValue: String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "EUR" + formatter.maximumFractionDigits = 2 + return formatter.string(from: value ?? 0) ?? "€0.00" + } + + var formattedDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: date) + } + + var monthYearString: String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM yyyy" + return formatter.string(from: date) + } +} + +// MARK: - Comparison with Previous + +extension Snapshot { + func change(from previousSnapshot: Snapshot?) -> Decimal { + guard let previous = previousSnapshot, + let previousValue = previous.value?.decimalValue, + previousValue != Decimal.zero else { + return Decimal.zero + } + + let currentValue = value?.decimalValue ?? Decimal.zero + return currentValue - previousValue + } + + func percentageChange(from previousSnapshot: Snapshot?) -> Decimal { + guard let previous = previousSnapshot, + let previousValue = previous.value?.decimalValue, + previousValue != Decimal.zero else { + return Decimal.zero + } + + let currentValue = value?.decimalValue ?? Decimal.zero + return ((currentValue - previousValue) / previousValue) * 100 + } +} diff --git a/InvestmentTracker/Models/CoreDataStack.swift b/InvestmentTracker/Models/CoreDataStack.swift new file mode 100644 index 0000000..bb431ae --- /dev/null +++ b/InvestmentTracker/Models/CoreDataStack.swift @@ -0,0 +1,144 @@ +import CoreData +import CloudKit + +class CoreDataStack: ObservableObject { + static let shared = CoreDataStack() + + static let appGroupIdentifier = "group.com.yourteam.investmenttracker" + static let cloudKitContainerIdentifier = "iCloud.com.yourteam.investmenttracker" + + lazy var persistentContainer: NSPersistentCloudKitContainer = { + let container = NSPersistentCloudKitContainer(name: "InvestmentTracker") + + // App Group store URL for sharing with widgets + guard let storeURL = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier)? + .appendingPathComponent("InvestmentTracker.sqlite") else { + fatalError("Unable to get App Group container URL") + } + + let description = NSPersistentStoreDescription(url: storeURL) + + // CloudKit configuration + description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions( + containerIdentifier: Self.cloudKitContainerIdentifier + ) + + // History tracking for sync + description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) + description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) + + container.persistentStoreDescriptions = [description] + + container.loadPersistentStores { description, error in + if let error = error as NSError? { + // In production, handle this error appropriately + print("Core Data failed to load: \(error), \(error.userInfo)") + } + } + + // Merge policy - remote changes win + container.viewContext.automaticallyMergesChangesFromParent = true + container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + + // Performance optimizations + container.viewContext.undoManager = nil + container.viewContext.shouldDeleteInaccessibleFaults = true + + // Listen for remote changes + NotificationCenter.default.addObserver( + self, + selector: #selector(processRemoteChanges), + name: .NSPersistentStoreRemoteChange, + object: container.persistentStoreCoordinator + ) + + return container + }() + + var viewContext: NSManagedObjectContext { + return persistentContainer.viewContext + } + + private init() {} + + // MARK: - Save Context + + func save() { + let context = viewContext + guard context.hasChanges else { return } + + do { + try context.save() + } catch { + let nsError = error as NSError + print("Core Data save error: \(nsError), \(nsError.userInfo)") + } + } + + // MARK: - Background Context + + func newBackgroundContext() -> NSManagedObjectContext { + let context = persistentContainer.newBackgroundContext() + context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + context.undoManager = nil + return context + } + + func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) { + persistentContainer.performBackgroundTask(block) + } + + // MARK: - Remote Change Handling + + @objc private func processRemoteChanges(_ notification: Notification) { + // Process remote changes on main context + DispatchQueue.main.async { [weak self] in + self?.objectWillChange.send() + } + } + + // MARK: - Widget Data Refresh + + func refreshWidgetData() { + // Trigger widget timeline refresh when data changes + #if os(iOS) + if #available(iOS 14.0, *) { + import WidgetKit + WidgetCenter.shared.reloadAllTimelines() + } + #endif + } +} + +// MARK: - Shared Container for Widgets + +extension CoreDataStack { + static var sharedStoreURL: URL? { + return FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)? + .appendingPathComponent("InvestmentTracker.sqlite") + } + + /// Creates a lightweight Core Data stack for widgets (read-only) + static func createWidgetContainer() -> NSPersistentContainer { + let container = NSPersistentContainer(name: "InvestmentTracker") + + guard let storeURL = sharedStoreURL else { + fatalError("Unable to get shared store URL") + } + + let description = NSPersistentStoreDescription(url: storeURL) + description.isReadOnly = true + + container.persistentStoreDescriptions = [description] + + container.loadPersistentStores { _, error in + if let error = error { + print("Widget Core Data failed: \(error)") + } + } + + return container + } +} diff --git a/InvestmentTracker/Models/InvestmentMetrics.swift b/InvestmentTracker/Models/InvestmentMetrics.swift new file mode 100644 index 0000000..a479476 --- /dev/null +++ b/InvestmentTracker/Models/InvestmentMetrics.swift @@ -0,0 +1,220 @@ +import Foundation + +struct InvestmentMetrics { + // Basic Metrics + let totalValue: Decimal + let totalContributions: Decimal + let absoluteReturn: Decimal + let percentageReturn: Decimal + + // Advanced Metrics + let cagr: Double // Compound Annual Growth Rate + let twr: Double // Time-Weighted Return + let volatility: Double // Standard deviation annualized + let maxDrawdown: Double // Maximum peak-to-trough decline + let sharpeRatio: Double // Risk-adjusted return + let bestMonth: MonthlyReturn? + let worstMonth: MonthlyReturn? + let winRate: Double // Percentage of positive months + let averageMonthlyReturn: Double + + // Time period + let startDate: Date? + let endDate: Date? + let totalMonths: Int + + struct MonthlyReturn: Identifiable { + let id = UUID() + let date: Date + let returnPercentage: Double + + var formattedDate: String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM yyyy" + return formatter.string(from: date) + } + + var formattedReturn: String { + String(format: "%.1f%%", returnPercentage) + } + } +} + +// MARK: - Formatting Helpers + +extension InvestmentMetrics { + var formattedTotalValue: String { + formatCurrency(totalValue) + } + + var formattedAbsoluteReturn: String { + let prefix = absoluteReturn >= 0 ? "+" : "" + return prefix + formatCurrency(absoluteReturn) + } + + var formattedPercentageReturn: String { + let prefix = percentageReturn >= 0 ? "+" : "" + return String(format: "\(prefix)%.2f%%", NSDecimalNumber(decimal: percentageReturn).doubleValue) + } + + var formattedCAGR: String { + String(format: "%.2f%%", cagr) + } + + var formattedTWR: String { + String(format: "%.2f%%", twr) + } + + var formattedVolatility: String { + String(format: "%.2f%%", volatility) + } + + var formattedMaxDrawdown: String { + String(format: "%.2f%%", maxDrawdown) + } + + var formattedSharpeRatio: String { + String(format: "%.2f", sharpeRatio) + } + + var formattedWinRate: String { + String(format: "%.0f%%", winRate) + } + + var formattedAverageMonthlyReturn: String { + let prefix = averageMonthlyReturn >= 0 ? "+" : "" + return String(format: "\(prefix)%.2f%%", averageMonthlyReturn) + } + + private func formatCurrency(_ value: Decimal) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "EUR" + formatter.maximumFractionDigits = 2 + return formatter.string(from: value as NSDecimalNumber) ?? "€0.00" + } +} + +// MARK: - Empty State + +extension InvestmentMetrics { + static var empty: InvestmentMetrics { + InvestmentMetrics( + totalValue: 0, + totalContributions: 0, + absoluteReturn: 0, + percentageReturn: 0, + cagr: 0, + twr: 0, + volatility: 0, + maxDrawdown: 0, + sharpeRatio: 0, + bestMonth: nil, + worstMonth: nil, + winRate: 0, + averageMonthlyReturn: 0, + startDate: nil, + endDate: nil, + totalMonths: 0 + ) + } +} + +// MARK: - Category Metrics + +struct CategoryMetrics: Identifiable { + let id: UUID + let categoryName: String + let colorHex: String + let icon: String + let totalValue: Decimal + let percentageOfPortfolio: Double + let metrics: InvestmentMetrics + + var formattedTotalValue: String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "EUR" + formatter.maximumFractionDigits = 0 + return formatter.string(from: totalValue as NSDecimalNumber) ?? "€0" + } + + var formattedPercentage: String { + String(format: "%.1f%%", percentageOfPortfolio) + } +} + +// MARK: - Portfolio Summary + +struct PortfolioSummary { + let totalValue: Decimal + let totalContributions: Decimal + let dayChange: Decimal + let dayChangePercentage: Double + let weekChange: Decimal + let weekChangePercentage: Double + let monthChange: Decimal + let monthChangePercentage: Double + let yearChange: Decimal + let yearChangePercentage: Double + let allTimeReturn: Decimal + let allTimeReturnPercentage: Double + let sourceCount: Int + let lastUpdated: Date? + + var formattedTotalValue: String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "EUR" + formatter.maximumFractionDigits = 0 + return formatter.string(from: totalValue as NSDecimalNumber) ?? "€0" + } + + var formattedDayChange: String { + formatChange(dayChange, dayChangePercentage) + } + + var formattedMonthChange: String { + formatChange(monthChange, monthChangePercentage) + } + + var formattedYearChange: String { + formatChange(yearChange, yearChangePercentage) + } + + var formattedAllTimeReturn: String { + formatChange(allTimeReturn, allTimeReturnPercentage) + } + + private func formatChange(_ absolute: Decimal, _ percentage: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "EUR" + formatter.maximumFractionDigits = 0 + + let prefix = absolute >= 0 ? "+" : "" + let absString = formatter.string(from: absolute as NSDecimalNumber) ?? "€0" + let pctString = String(format: "%.2f%%", percentage) + + return "\(prefix)\(absString) (\(prefix)\(pctString))" + } + + static var empty: PortfolioSummary { + PortfolioSummary( + totalValue: 0, + totalContributions: 0, + dayChange: 0, + dayChangePercentage: 0, + weekChange: 0, + weekChangePercentage: 0, + monthChange: 0, + monthChangePercentage: 0, + yearChange: 0, + yearChangePercentage: 0, + allTimeReturn: 0, + allTimeReturnPercentage: 0, + sourceCount: 0, + lastUpdated: nil + ) + } +} diff --git a/InvestmentTracker/Models/Prediction.swift b/InvestmentTracker/Models/Prediction.swift new file mode 100644 index 0000000..0783837 --- /dev/null +++ b/InvestmentTracker/Models/Prediction.swift @@ -0,0 +1,106 @@ +import Foundation + +struct Prediction: Codable, Identifiable { + let id: UUID + let date: Date + let predictedValue: Decimal + let algorithm: PredictionAlgorithm + let confidenceInterval: ConfidenceInterval + + init( + id: UUID = UUID(), + date: Date, + predictedValue: Decimal, + algorithm: PredictionAlgorithm, + confidenceInterval: ConfidenceInterval + ) { + self.id = id + self.date = date + self.predictedValue = predictedValue + self.algorithm = algorithm + self.confidenceInterval = confidenceInterval + } + + struct ConfidenceInterval: Codable { + let lower: Decimal + let upper: Decimal + + var range: Decimal { + upper - lower + } + + var percentageWidth: Decimal { + guard upper != 0 else { return 0 } + return (range / upper) * 100 + } + } +} + +// MARK: - Prediction Algorithm Codable + +extension PredictionAlgorithm: Codable {} + +// MARK: - Prediction Helpers + +extension Prediction { + var formattedValue: String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "EUR" + formatter.maximumFractionDigits = 0 + return formatter.string(from: predictedValue as NSDecimalNumber) ?? "€0" + } + + var formattedDate: String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM yyyy" + return formatter.string(from: date) + } + + var formattedConfidenceRange: String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "EUR" + formatter.maximumFractionDigits = 0 + + let lower = formatter.string(from: confidenceInterval.lower as NSDecimalNumber) ?? "€0" + let upper = formatter.string(from: confidenceInterval.upper as NSDecimalNumber) ?? "€0" + + return "\(lower) - \(upper)" + } +} + +// MARK: - Prediction Result + +struct PredictionResult { + let predictions: [Prediction] + let algorithm: PredictionAlgorithm + let accuracy: Double // R-squared or similar metric + let volatility: Double + + var isHighConfidence: Bool { + accuracy >= 0.7 + } + + var confidenceLevel: ConfidenceLevel { + switch accuracy { + case 0.8...: return .high + case 0.5..<0.8: return .medium + default: return .low + } + } + + enum ConfidenceLevel: String { + case high = "High" + case medium = "Medium" + case low = "Low" + + var color: String { + switch self { + case .high: return "#10B981" + case .medium: return "#F59E0B" + case .low: return "#EF4444" + } + } + } +} diff --git a/InvestmentTracker/Persistence.swift b/InvestmentTracker/Persistence.swift deleted file mode 100644 index d379325..0000000 --- a/InvestmentTracker/Persistence.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// Persistence.swift -// InvestmentTracker -// -// Created by Alexandre Vazquez on 06/01/2026. -// - -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: NSPersistentCloudKitContainer - - init(inMemory: Bool = false) { - container = NSPersistentCloudKitContainer(name: "InvestmentTracker") - 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/InvestmentTracker/Repositories/CategoryRepository.swift b/InvestmentTracker/Repositories/CategoryRepository.swift new file mode 100644 index 0000000..606beab --- /dev/null +++ b/InvestmentTracker/Repositories/CategoryRepository.swift @@ -0,0 +1,147 @@ +import Foundation +import CoreData +import Combine + +class CategoryRepository: ObservableObject { + private let context: NSManagedObjectContext + + @Published private(set) var categories: [Category] = [] + + init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) { + self.context = context + fetchCategories() + setupNotificationObserver() + } + + private func setupNotificationObserver() { + NotificationCenter.default.addObserver( + self, + selector: #selector(contextDidChange), + name: .NSManagedObjectContextObjectsDidChange, + object: context + ) + } + + @objc private func contextDidChange(_ notification: Notification) { + fetchCategories() + } + + // MARK: - Fetch + + func fetchCategories() { + let request: NSFetchRequest = Category.fetchRequest() + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true), + NSSortDescriptor(keyPath: \Category.name, ascending: true) + ] + + do { + categories = try context.fetch(request) + } catch { + print("Failed to fetch categories: \(error)") + categories = [] + } + } + + func fetchCategory(by id: UUID) -> Category? { + let request: NSFetchRequest = Category.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", id as CVarArg) + request.fetchLimit = 1 + return try? context.fetch(request).first + } + + // MARK: - Create + + @discardableResult + func createCategory( + name: String, + colorHex: String, + icon: String + ) -> Category { + let category = Category(context: context) + category.name = name + category.colorHex = colorHex + category.icon = icon + category.sortOrder = Int16(categories.count) + + save() + return category + } + + func createDefaultCategoriesIfNeeded() { + guard categories.isEmpty else { return } + Category.createDefaultCategories(in: context) + fetchCategories() + } + + // MARK: - Update + + func updateCategory( + _ category: Category, + name: String? = nil, + colorHex: String? = nil, + icon: String? = nil + ) { + if let name = name { + category.name = name + } + if let colorHex = colorHex { + category.colorHex = colorHex + } + if let icon = icon { + category.icon = icon + } + + save() + } + + func updateSortOrder(_ categories: [Category]) { + for (index, category) in categories.enumerated() { + category.sortOrder = Int16(index) + } + save() + } + + // MARK: - Delete + + func deleteCategory(_ category: Category) { + context.delete(category) + save() + } + + func deleteCategory(at offsets: IndexSet) { + for index in offsets { + guard index < categories.count else { continue } + context.delete(categories[index]) + } + save() + } + + // MARK: - Statistics + + var totalValue: Decimal { + categories.reduce(Decimal.zero) { $0 + $1.totalValue } + } + + func categoryAllocation() -> [(category: Category, percentage: Double)] { + let total = totalValue + guard total > 0 else { return [] } + + return categories.map { category in + let percentage = NSDecimalNumber(decimal: category.totalValue / total).doubleValue * 100 + return (category: category, percentage: percentage) + }.sorted { $0.percentage > $1.percentage } + } + + // MARK: - Save + + private func save() { + guard context.hasChanges else { return } + do { + try context.save() + fetchCategories() + } catch { + print("Failed to save context: \(error)") + } + } +} diff --git a/InvestmentTracker/Repositories/InvestmentSourceRepository.swift b/InvestmentTracker/Repositories/InvestmentSourceRepository.swift new file mode 100644 index 0000000..db1da91 --- /dev/null +++ b/InvestmentTracker/Repositories/InvestmentSourceRepository.swift @@ -0,0 +1,177 @@ +import Foundation +import CoreData +import Combine + +class InvestmentSourceRepository: ObservableObject { + private let context: NSManagedObjectContext + + @Published private(set) var sources: [InvestmentSource] = [] + + init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) { + self.context = context + fetchSources() + setupNotificationObserver() + } + + private func setupNotificationObserver() { + NotificationCenter.default.addObserver( + self, + selector: #selector(contextDidChange), + name: .NSManagedObjectContextObjectsDidChange, + object: context + ) + } + + @objc private func contextDidChange(_ notification: Notification) { + fetchSources() + } + + // MARK: - Fetch + + func fetchSources() { + let request: NSFetchRequest = InvestmentSource.fetchRequest() + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true) + ] + + do { + sources = try context.fetch(request) + } catch { + print("Failed to fetch sources: \(error)") + sources = [] + } + } + + func fetchSource(by id: UUID) -> InvestmentSource? { + let request: NSFetchRequest = InvestmentSource.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", id as CVarArg) + request.fetchLimit = 1 + return try? context.fetch(request).first + } + + func fetchSources(for category: Category) -> [InvestmentSource] { + let request: NSFetchRequest = InvestmentSource.fetchRequest() + request.predicate = NSPredicate(format: "category == %@", category) + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true) + ] + + return (try? context.fetch(request)) ?? [] + } + + func fetchActiveSources() -> [InvestmentSource] { + sources.filter { $0.isActive } + } + + func fetchSourcesNeedingUpdate() -> [InvestmentSource] { + sources.filter { $0.needsUpdate } + } + + // MARK: - Create + + @discardableResult + func createSource( + name: String, + category: Category, + notificationFrequency: NotificationFrequency = .monthly, + customFrequencyMonths: Int = 1 + ) -> InvestmentSource { + let source = InvestmentSource(context: context) + source.name = name + source.category = category + source.notificationFrequency = notificationFrequency.rawValue + source.customFrequencyMonths = Int16(customFrequencyMonths) + + save() + return source + } + + // MARK: - Update + + func updateSource( + _ source: InvestmentSource, + name: String? = nil, + category: Category? = nil, + notificationFrequency: NotificationFrequency? = nil, + customFrequencyMonths: Int? = nil, + isActive: Bool? = nil + ) { + if let name = name { + source.name = name + } + if let category = category { + source.category = category + } + if let frequency = notificationFrequency { + source.notificationFrequency = frequency.rawValue + } + if let customMonths = customFrequencyMonths { + source.customFrequencyMonths = Int16(customMonths) + } + if let isActive = isActive { + source.isActive = isActive + } + + save() + } + + func toggleActive(_ source: InvestmentSource) { + source.isActive.toggle() + save() + } + + // MARK: - Delete + + func deleteSource(_ source: InvestmentSource) { + context.delete(source) + save() + } + + func deleteSource(at offsets: IndexSet) { + for index in offsets { + guard index < sources.count else { continue } + context.delete(sources[index]) + } + save() + } + + // MARK: - Statistics + + var sourceCount: Int { + sources.count + } + + var activeSourceCount: Int { + sources.filter { $0.isActive }.count + } + + var totalValue: Decimal { + sources.reduce(Decimal.zero) { $0 + $1.latestValue } + } + + func topSources(limit: Int = 5) -> [InvestmentSource] { + sources + .sorted { $0.latestValue > $1.latestValue } + .prefix(limit) + .map { $0 } + } + + // MARK: - Freemium Check + + var canAddMoreSources: Bool { + // This will be checked against FreemiumValidator + true + } + + // MARK: - Save + + private func save() { + guard context.hasChanges else { return } + do { + try context.save() + fetchSources() + } catch { + print("Failed to save context: \(error)") + } + } +} diff --git a/InvestmentTracker/Repositories/SnapshotRepository.swift b/InvestmentTracker/Repositories/SnapshotRepository.swift new file mode 100644 index 0000000..88b0a53 --- /dev/null +++ b/InvestmentTracker/Repositories/SnapshotRepository.swift @@ -0,0 +1,201 @@ +import Foundation +import CoreData +import Combine + +class SnapshotRepository: ObservableObject { + private let context: NSManagedObjectContext + + @Published private(set) var snapshots: [Snapshot] = [] + + init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) { + self.context = context + } + + // MARK: - Fetch + + func fetchSnapshots(for source: InvestmentSource) -> [Snapshot] { + let request: NSFetchRequest = Snapshot.fetchRequest() + request.predicate = NSPredicate(format: "source == %@", source) + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \Snapshot.date, ascending: false) + ] + + return (try? context.fetch(request)) ?? [] + } + + func fetchSnapshot(by id: UUID) -> Snapshot? { + let request: NSFetchRequest = Snapshot.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", id as CVarArg) + request.fetchLimit = 1 + return try? context.fetch(request).first + } + + func fetchAllSnapshots() -> [Snapshot] { + let request: NSFetchRequest = Snapshot.fetchRequest() + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \Snapshot.date, ascending: false) + ] + + return (try? context.fetch(request)) ?? [] + } + + func fetchSnapshots(from startDate: Date, to endDate: Date) -> [Snapshot] { + let request: NSFetchRequest = Snapshot.fetchRequest() + request.predicate = NSPredicate( + format: "date >= %@ AND date <= %@", + startDate as NSDate, + endDate as NSDate + ) + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \Snapshot.date, ascending: true) + ] + + return (try? context.fetch(request)) ?? [] + } + + func fetchLatestSnapshots() -> [Snapshot] { + // Get the latest snapshot for each source + let request: NSFetchRequest = InvestmentSource.fetchRequest() + let sources = (try? context.fetch(request)) ?? [] + + return sources.compactMap { $0.latestSnapshot } + } + + // MARK: - Create + + @discardableResult + func createSnapshot( + for source: InvestmentSource, + date: Date = Date(), + value: Decimal, + contribution: Decimal? = nil, + notes: String? = nil + ) -> Snapshot { + let snapshot = Snapshot(context: context) + snapshot.source = source + snapshot.date = date + snapshot.value = NSDecimalNumber(decimal: value) + if let contribution = contribution { + snapshot.contribution = NSDecimalNumber(decimal: contribution) + } + snapshot.notes = notes + + save() + return snapshot + } + + // MARK: - Update + + func updateSnapshot( + _ snapshot: Snapshot, + date: Date? = nil, + value: Decimal? = nil, + contribution: Decimal? = nil, + notes: String? = nil + ) { + if let date = date { + snapshot.date = date + } + if let value = value { + snapshot.value = NSDecimalNumber(decimal: value) + } + if let contribution = contribution { + snapshot.contribution = NSDecimalNumber(decimal: contribution) + } + if let notes = notes { + snapshot.notes = notes + } + + save() + } + + // MARK: - Delete + + func deleteSnapshot(_ snapshot: Snapshot) { + context.delete(snapshot) + save() + } + + func deleteSnapshots(_ snapshots: [Snapshot]) { + snapshots.forEach { context.delete($0) } + save() + } + + func deleteSnapshot(at offsets: IndexSet, from snapshots: [Snapshot]) { + for index in offsets { + guard index < snapshots.count else { continue } + context.delete(snapshots[index]) + } + save() + } + + // MARK: - Filtered Fetch (Freemium) + + func fetchSnapshots( + for source: InvestmentSource, + limitedToMonths months: Int? + ) -> [Snapshot] { + var snapshots = fetchSnapshots(for: source) + + if let months = months { + let cutoffDate = Calendar.current.date( + byAdding: .month, + value: -months, + to: Date() + ) ?? Date() + + snapshots = snapshots.filter { $0.date >= cutoffDate } + } + + return snapshots + } + + // MARK: - Historical Data + + func getMonthlyValues(for source: InvestmentSource) -> [(date: Date, value: Decimal)] { + let snapshots = fetchSnapshots(for: source).reversed() + + var monthlyData: [(date: Date, value: Decimal)] = [] + var processedMonths: Set = [] + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM" + + for snapshot in snapshots { + let monthKey = formatter.string(from: snapshot.date) + if !processedMonths.contains(monthKey) { + processedMonths.insert(monthKey) + monthlyData.append((date: snapshot.date, value: snapshot.decimalValue)) + } + } + + return monthlyData + } + + func getPortfolioHistory() -> [(date: Date, totalValue: Decimal)] { + let allSnapshots = fetchAllSnapshots() + + // Group by date + var dateValues: [Date: Decimal] = [:] + + for snapshot in allSnapshots { + let startOfDay = Calendar.current.startOfDay(for: snapshot.date) + dateValues[startOfDay, default: Decimal.zero] += snapshot.decimalValue + } + + return dateValues + .map { (date: $0.key, totalValue: $0.value) } + .sorted { $0.date < $1.date } + } + + // MARK: - Save + + private func save() { + guard context.hasChanges else { return } + do { + try context.save() + } catch { + print("Failed to save context: \(error)") + } + } +} diff --git a/InvestmentTracker/Resources/Info.plist b/InvestmentTracker/Resources/Info.plist new file mode 100644 index 0000000..c52235a --- /dev/null +++ b/InvestmentTracker/Resources/Info.plist @@ -0,0 +1,118 @@ + + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Investment Tracker + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + + UILaunchScreen + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + + + GADApplicationIdentifier + ca-app-pub-3940256099942544~1458002511 + + + GADDelayAppMeasurementInit + + + + NSUserTrackingUsageDescription + This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties. + + + NSUbiquitousContainerIsDocumentScopePublic + + + + UIBackgroundModes + + fetch + remote-notification + + + + NSCalendarsUsageDescription + Used to set investment update reminders. + + + CFBundleURLTypes + + + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleURLSchemes + + investmenttracker + + + + + + UISupportsDocumentBrowser + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsForMedia + + NSAllowsArbitraryLoadsInWebContent + + + + + UIStatusBarStyle + UIStatusBarStyleDefault + UIViewControllerBasedStatusBarAppearance + + + diff --git a/InvestmentTracker/Resources/Localizable.strings b/InvestmentTracker/Resources/Localizable.strings new file mode 100644 index 0000000..68c776d --- /dev/null +++ b/InvestmentTracker/Resources/Localizable.strings @@ -0,0 +1,188 @@ +/* + Localizable.strings + InvestmentTracker + + English (Base) localization +*/ + +// MARK: - General +"app_name" = "Investment Tracker"; +"ok" = "OK"; +"cancel" = "Cancel"; +"save" = "Save"; +"delete" = "Delete"; +"edit" = "Edit"; +"add" = "Add"; +"done" = "Done"; +"close" = "Close"; +"continue" = "Continue"; +"skip" = "Skip"; +"error" = "Error"; +"success" = "Success"; +"loading" = "Loading..."; + +// MARK: - Tab Bar +"tab_dashboard" = "Dashboard"; +"tab_sources" = "Sources"; +"tab_charts" = "Charts"; +"tab_settings" = "Settings"; + +// MARK: - Dashboard +"dashboard_title" = "Dashboard"; +"total_portfolio_value" = "Total Portfolio Value"; +"today" = "today"; +"returns" = "Returns"; +"by_category" = "By Category"; +"pending_updates" = "Pending Updates"; +"see_all" = "See All"; + +// MARK: - Sources +"sources_title" = "Sources"; +"add_source" = "Add Source"; +"source_name" = "Source Name"; +"select_category" = "Select Category"; +"initial_value" = "Initial Value"; +"initial_value_optional" = "Initial Value (Optional)"; +"reminder_frequency" = "Reminder Frequency"; +"source_limit_warning" = "Source limit reached. Upgrade to Premium for unlimited sources."; +"no_sources" = "No Investment Sources"; +"no_sources_message" = "Add your first investment source to start tracking your portfolio."; + +// MARK: - Snapshots +"add_snapshot" = "Add Snapshot"; +"edit_snapshot" = "Edit Snapshot"; +"snapshot_date" = "Date"; +"snapshot_value" = "Value"; +"snapshot_contribution" = "Contribution"; +"contribution_optional" = "Contribution (Optional)"; +"notes" = "Notes"; +"notes_optional" = "Notes (Optional)"; +"previous_value" = "Previous: %@"; +"change_from_previous" = "Change from previous"; + +// MARK: - Charts +"charts_title" = "Charts"; +"evolution" = "Evolution"; +"allocation" = "Allocation"; +"performance" = "Performance"; +"drawdown" = "Drawdown"; +"volatility" = "Volatility"; +"prediction" = "Prediction"; +"portfolio_evolution" = "Portfolio Evolution"; +"asset_allocation" = "Asset Allocation"; +"performance_by_category" = "Performance by Category"; +"drawdown_analysis" = "Drawdown Analysis"; +"prediction_12_month" = "12-Month Prediction"; +"not_enough_data" = "Not enough data"; + +// MARK: - Metrics +"cagr" = "CAGR"; +"twr" = "TWR"; +"max_drawdown" = "Max Drawdown"; +"sharpe_ratio" = "Sharpe Ratio"; +"win_rate" = "Win Rate"; +"avg_monthly" = "Avg Monthly"; +"best_month" = "Best Month"; +"worst_month" = "Worst Month"; + +// MARK: - Premium +"premium" = "Premium"; +"upgrade_to_premium" = "Upgrade to Premium"; +"unlock_full_potential" = "Unlock Full Potential"; +"one_time_purchase" = "One-time purchase"; +"includes_family_sharing" = "Includes Family Sharing"; +"upgrade_now" = "Upgrade Now"; +"restore_purchases" = "Restore Purchases"; +"premium_active" = "Premium Active"; +"premium_feature" = "Premium Feature"; +"unlock" = "Unlock"; + +// Premium Features +"feature_unlimited_sources" = "Unlimited Sources"; +"feature_unlimited_sources_desc" = "Track as many investments as you want"; +"feature_full_history" = "Full History"; +"feature_full_history_desc" = "Access your complete investment history"; +"feature_advanced_charts" = "Advanced Charts"; +"feature_advanced_charts_desc" = "5 types of detailed analytics charts"; +"feature_predictions" = "Predictions"; +"feature_predictions_desc" = "AI-powered 12-month forecasts"; +"feature_export" = "Export Data"; +"feature_export_desc" = "Export to CSV and JSON formats"; +"feature_no_ads" = "No Ads"; +"feature_no_ads_desc" = "Ad-free experience forever"; + +// MARK: - Settings +"settings_title" = "Settings"; +"subscription" = "Subscription"; +"notifications" = "Notifications"; +"default_reminder_time" = "Default Reminder Time"; +"data" = "Data"; +"export_data" = "Export Data"; +"total_sources" = "Total Sources"; +"total_snapshots" = "Total Snapshots"; +"storage_used" = "Storage Used"; +"about" = "About"; +"version" = "Version"; +"privacy_policy" = "Privacy Policy"; +"terms_of_service" = "Terms of Service"; +"support" = "Support"; +"rate_app" = "Rate App"; +"danger_zone" = "Danger Zone"; +"reset_all_data" = "Reset All Data"; +"reset_confirmation" = "This will permanently delete all your investment data. This action cannot be undone."; + +// MARK: - Notification Frequencies +"frequency_monthly" = "Monthly"; +"frequency_quarterly" = "Quarterly"; +"frequency_semiannual" = "Semi-Annual"; +"frequency_annual" = "Annual"; +"frequency_custom" = "Custom"; +"frequency_never" = "Never"; +"every_n_months" = "Every %d month(s)"; + +// MARK: - Categories +"category_stocks" = "Stocks"; +"category_bonds" = "Bonds"; +"category_real_estate" = "Real Estate"; +"category_crypto" = "Crypto"; +"category_cash" = "Cash"; +"category_etfs" = "ETFs"; +"category_retirement" = "Retirement"; +"category_other" = "Other"; +"uncategorized" = "Uncategorized"; + +// MARK: - Time Ranges +"time_1m" = "1M"; +"time_3m" = "3M"; +"time_6m" = "6M"; +"time_1y" = "1Y"; +"time_all" = "All"; + +// MARK: - Export +"export_format" = "Select Format"; +"export_csv" = "CSV"; +"export_csv_desc" = "Compatible with Excel, Google Sheets"; +"export_json" = "JSON"; +"export_json_desc" = "Full data structure for backup"; + +// MARK: - Onboarding +"onboarding_track_title" = "Track Your Investments"; +"onboarding_track_desc" = "Monitor all your investment sources in one place. Stocks, bonds, real estate, crypto, and more."; +"onboarding_visualize_title" = "Visualize Your Growth"; +"onboarding_visualize_desc" = "Beautiful charts show your portfolio evolution, allocation, and performance over time."; +"onboarding_reminders_title" = "Never Miss an Update"; +"onboarding_reminders_desc" = "Set reminders to track your investments regularly. Monthly, quarterly, or custom schedules."; +"onboarding_sync_title" = "Sync Everywhere"; +"onboarding_sync_desc" = "Your data syncs automatically via iCloud across all your Apple devices."; +"get_started" = "Get Started"; + +// MARK: - Errors +"error_generic" = "An error occurred. Please try again."; +"error_no_purchases" = "No purchases found to restore"; +"error_purchase_failed" = "Purchase failed: %@"; +"error_export_failed" = "Export failed. Please try again."; + +// MARK: - Placeholders +"placeholder_source_name" = "e.g., Vanguard 401k"; +"placeholder_value" = "0.00"; +"placeholder_notes" = "Add notes..."; diff --git a/InvestmentTracker/Services/AdMobService.swift b/InvestmentTracker/Services/AdMobService.swift new file mode 100644 index 0000000..f46d992 --- /dev/null +++ b/InvestmentTracker/Services/AdMobService.swift @@ -0,0 +1,177 @@ +import Foundation +import GoogleMobileAds +import AppTrackingTransparency +import AdSupport + +@MainActor +class AdMobService: ObservableObject { + // MARK: - Published Properties + + @Published var isConsentObtained = false + @Published var canShowAds = false + @Published var isLoading = false + + // MARK: - Ad Unit IDs + + // Test Ad Unit IDs - Replace with production IDs before release + #if DEBUG + static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716" + #else + static let bannerAdUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY" // Replace with your Ad Unit ID + #endif + + // MARK: - Initialization + + init() { + checkConsentStatus() + } + + // MARK: - Consent Management + + func checkConsentStatus() { + // Check if we already have consent + let consentStatus = UserDefaults.standard.bool(forKey: "adConsentObtained") + isConsentObtained = consentStatus + canShowAds = consentStatus + } + + func requestConsent() async { + // Request App Tracking Transparency authorization + if #available(iOS 14.5, *) { + let status = await ATTrackingManager.requestTrackingAuthorization() + + switch status { + case .authorized: + isConsentObtained = true + canShowAds = true + case .denied, .restricted: + // Can still show non-personalized ads + isConsentObtained = true + canShowAds = true + case .notDetermined: + // Will be asked again later + break + @unknown default: + break + } + } else { + // iOS 14.4 and earlier - consent assumed + isConsentObtained = true + canShowAds = true + } + + UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained") + } + + // MARK: - GDPR Consent (UMP SDK) + + func requestGDPRConsent() async { + // Implement UMP SDK consent flow if targeting EU users + // This is a simplified version - full implementation requires UMP SDK + + let isEUUser = isUserInEU() + + if isEUUser { + // Show GDPR consent dialog + // For now, assume consent if user continues + isConsentObtained = true + canShowAds = true + } else { + isConsentObtained = true + canShowAds = true + } + + UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained") + } + + private func isUserInEU() -> Bool { + let euCountries = [ + "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", + "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", + "PL", "PT", "RO", "SK", "SI", "ES", "SE", "GB", "IS", "LI", + "NO", "CH" + ] + + let countryCode = Locale.current.region?.identifier ?? "" + return euCountries.contains(countryCode) + } + + // MARK: - Analytics + + func logAdImpression() { + FirebaseService.shared.logAdImpression(adType: "banner") + } + + func logAdClick() { + FirebaseService.shared.logAdClick(adType: "banner") + } +} + +// MARK: - Banner Ad Coordinator + +class BannerAdCoordinator: NSObject, GADBannerViewDelegate { + weak var adMobService: AdMobService? + + func bannerViewDidReceiveAd(_ bannerView: GADBannerView) { + print("Banner ad received") + adMobService?.logAdImpression() + } + + func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) { + print("Banner ad failed to load: \(error.localizedDescription)") + } + + func bannerViewDidRecordImpression(_ bannerView: GADBannerView) { + // Impression recorded + } + + func bannerViewDidRecordClick(_ bannerView: GADBannerView) { + adMobService?.logAdClick() + } + + func bannerViewWillPresentScreen(_ bannerView: GADBannerView) { + // Ad will present full screen + } + + func bannerViewWillDismissScreen(_ bannerView: GADBannerView) { + // Ad will dismiss + } + + func bannerViewDidDismissScreen(_ bannerView: GADBannerView) { + // Ad dismissed + } +} + +// MARK: - UIKit Banner View Wrapper + +import SwiftUI + +struct BannerAdView: UIViewRepresentable { + @EnvironmentObject var adMobService: AdMobService + + func makeUIView(context: Context) -> GADBannerView { + let bannerView = GADBannerView(adSize: GADAdSizeBanner) + bannerView.adUnitID = AdMobService.bannerAdUnitID + + // Get root view controller + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController { + bannerView.rootViewController = rootViewController + } + + bannerView.delegate = context.coordinator + bannerView.load(GADRequest()) + + return bannerView + } + + func updateUIView(_ uiView: GADBannerView, context: Context) { + // Banner updates automatically + } + + func makeCoordinator() -> BannerAdCoordinator { + let coordinator = BannerAdCoordinator() + coordinator.adMobService = adMobService + return coordinator + } +} diff --git a/InvestmentTracker/Services/CalculationService.swift b/InvestmentTracker/Services/CalculationService.swift new file mode 100644 index 0000000..eaf6c16 --- /dev/null +++ b/InvestmentTracker/Services/CalculationService.swift @@ -0,0 +1,334 @@ +import Foundation + +class CalculationService { + static let shared = CalculationService() + + private init() {} + + // MARK: - Portfolio Summary + + func calculatePortfolioSummary( + from sources: [InvestmentSource], + snapshots: [Snapshot] + ) -> PortfolioSummary { + let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue } + let totalContributions = sources.reduce(Decimal.zero) { $0 + $1.totalContributions } + + // Calculate period changes + let now = Date() + let dayAgo = Calendar.current.date(byAdding: .day, value: -1, to: now) ?? now + let weekAgo = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: now) ?? now + let monthAgo = Calendar.current.date(byAdding: .month, value: -1, to: now) ?? now + let yearAgo = Calendar.current.date(byAdding: .year, value: -1, to: now) ?? now + + let dayChange = calculatePeriodChange(sources: sources, from: dayAgo) + let weekChange = calculatePeriodChange(sources: sources, from: weekAgo) + let monthChange = calculatePeriodChange(sources: sources, from: monthAgo) + let yearChange = calculatePeriodChange(sources: sources, from: yearAgo) + + let allTimeReturn = totalValue - totalContributions + let allTimeReturnPercentage = totalContributions > 0 + ? NSDecimalNumber(decimal: allTimeReturn / totalContributions).doubleValue * 100 + : 0 + + let lastUpdated = snapshots.map { $0.date }.max() + + return PortfolioSummary( + totalValue: totalValue, + totalContributions: totalContributions, + dayChange: dayChange.absolute, + dayChangePercentage: dayChange.percentage, + weekChange: weekChange.absolute, + weekChangePercentage: weekChange.percentage, + monthChange: monthChange.absolute, + monthChangePercentage: monthChange.percentage, + yearChange: yearChange.absolute, + yearChangePercentage: yearChange.percentage, + allTimeReturn: allTimeReturn, + allTimeReturnPercentage: allTimeReturnPercentage, + sourceCount: sources.count, + lastUpdated: lastUpdated + ) + } + + private func calculatePeriodChange( + sources: [InvestmentSource], + from startDate: Date + ) -> (absolute: Decimal, percentage: Double) { + var previousTotal: Decimal = 0 + var currentTotal: Decimal = 0 + + for source in sources { + currentTotal += source.latestValue + + // Find snapshot closest to start date + let snapshots = source.sortedSnapshotsByDateAscending + let previousSnapshot = snapshots.last { $0.date <= startDate } ?? snapshots.first + previousTotal += previousSnapshot?.decimalValue ?? 0 + } + + let absolute = currentTotal - previousTotal + let percentage = previousTotal > 0 + ? NSDecimalNumber(decimal: absolute / previousTotal).doubleValue * 100 + : 0 + + return (absolute, percentage) + } + + // MARK: - Investment Metrics + + func calculateMetrics(for snapshots: [Snapshot]) -> InvestmentMetrics { + guard !snapshots.isEmpty else { return .empty } + + let sortedSnapshots = snapshots.sorted { $0.date < $1.date } + let values = sortedSnapshots.map { $0.decimalValue } + + guard let firstValue = values.first, + let lastValue = values.last, + firstValue != 0 else { + return .empty + } + + let totalValue = lastValue + let totalContributions = sortedSnapshots.reduce(Decimal.zero) { $0 + $1.decimalContribution } + let absoluteReturn = lastValue - firstValue + let percentageReturn = (absoluteReturn / firstValue) * 100 + + // Calculate monthly returns for advanced metrics + let monthlyReturns = calculateMonthlyReturns(from: sortedSnapshots) + + let cagr = calculateCAGR( + startValue: firstValue, + endValue: lastValue, + startDate: sortedSnapshots.first?.date ?? Date(), + endDate: sortedSnapshots.last?.date ?? Date() + ) + + let twr = calculateTWR(snapshots: sortedSnapshots) + let volatility = calculateVolatility(monthlyReturns: monthlyReturns) + let maxDrawdown = calculateMaxDrawdown(values: values) + let sharpeRatio = calculateSharpeRatio( + averageReturn: monthlyReturns.map { $0.returnPercentage }.average(), + volatility: volatility + ) + + let winRate = calculateWinRate(monthlyReturns: monthlyReturns) + let averageMonthlyReturn = monthlyReturns.map { $0.returnPercentage }.average() + + return InvestmentMetrics( + totalValue: totalValue, + totalContributions: totalContributions, + absoluteReturn: absoluteReturn, + percentageReturn: percentageReturn, + cagr: cagr, + twr: twr, + volatility: volatility, + maxDrawdown: maxDrawdown, + sharpeRatio: sharpeRatio, + bestMonth: monthlyReturns.max(by: { $0.returnPercentage < $1.returnPercentage }), + worstMonth: monthlyReturns.min(by: { $0.returnPercentage < $1.returnPercentage }), + winRate: winRate, + averageMonthlyReturn: averageMonthlyReturn, + startDate: sortedSnapshots.first?.date, + endDate: sortedSnapshots.last?.date, + totalMonths: monthlyReturns.count + ) + } + + // MARK: - CAGR (Compound Annual Growth Rate) + + func calculateCAGR( + startValue: Decimal, + endValue: Decimal, + startDate: Date, + endDate: Date + ) -> Double { + guard startValue > 0 else { return 0 } + + let years = Calendar.current.dateComponents( + [.day], + from: startDate, + to: endDate + ).day.map { Double($0) / 365.25 } ?? 0 + + guard years > 0 else { return 0 } + + let ratio = NSDecimalNumber(decimal: endValue / startValue).doubleValue + let cagr = pow(ratio, 1 / years) - 1 + + return cagr * 100 + } + + // MARK: - TWR (Time-Weighted Return) + + func calculateTWR(snapshots: [Snapshot]) -> Double { + guard snapshots.count >= 2 else { return 0 } + + var twr: Double = 1.0 + + for i in 1.. 0 else { continue } + + // Adjust for contributions + let adjustedPreviousValue = previousValue + contribution + let periodReturn = NSDecimalNumber( + decimal: currentValue / adjustedPreviousValue + ).doubleValue + + twr *= periodReturn + } + + return (twr - 1) * 100 + } + + // MARK: - Volatility (Annualized Standard Deviation) + + func calculateVolatility(monthlyReturns: [InvestmentMetrics.MonthlyReturn]) -> Double { + let returns = monthlyReturns.map { $0.returnPercentage } + guard returns.count >= 2 else { return 0 } + + let mean = returns.average() + let squaredDifferences = returns.map { pow($0 - mean, 2) } + let variance = squaredDifferences.reduce(0, +) / Double(returns.count - 1) + let stdDev = sqrt(variance) + + // Annualize (multiply by sqrt(12) for monthly data) + return stdDev * sqrt(12) + } + + // MARK: - Max Drawdown + + func calculateMaxDrawdown(values: [Decimal]) -> Double { + guard !values.isEmpty else { return 0 } + + var maxDrawdown: Double = 0 + var peak = values[0] + + for value in values { + if value > peak { + peak = value + } + + guard peak > 0 else { continue } + + let drawdown = NSDecimalNumber( + decimal: (peak - value) / peak + ).doubleValue * 100 + + maxDrawdown = max(maxDrawdown, drawdown) + } + + return maxDrawdown + } + + // MARK: - Sharpe Ratio + + func calculateSharpeRatio( + averageReturn: Double, + volatility: Double, + riskFreeRate: Double = 2.0 // Assume 2% annual risk-free rate + ) -> Double { + guard volatility > 0 else { return 0 } + + // Convert to monthly risk-free rate + let monthlyRiskFree = riskFreeRate / 12 + + return (averageReturn - monthlyRiskFree) / volatility * sqrt(12) + } + + // MARK: - Win Rate + + func calculateWinRate(monthlyReturns: [InvestmentMetrics.MonthlyReturn]) -> Double { + guard !monthlyReturns.isEmpty else { return 0 } + + let positiveMonths = monthlyReturns.filter { $0.returnPercentage > 0 }.count + return Double(positiveMonths) / Double(monthlyReturns.count) * 100 + } + + // MARK: - Monthly Returns + + func calculateMonthlyReturns(from snapshots: [Snapshot]) -> [InvestmentMetrics.MonthlyReturn] { + guard snapshots.count >= 2 else { return [] } + + var monthlyReturns: [InvestmentMetrics.MonthlyReturn] = [] + let calendar = Calendar.current + + // Group snapshots by month + var monthlySnapshots: [String: [Snapshot]] = [:] + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM" + + for snapshot in snapshots { + let key = formatter.string(from: snapshot.date) + monthlySnapshots[key, default: []].append(snapshot) + } + + // Sort months + let sortedMonths = monthlySnapshots.keys.sorted() + + for i in 1.. 0 else { + continue + } + + let returnPercentage = NSDecimalNumber( + decimal: (currentValue - previousValue) / previousValue + ).doubleValue * 100 + + if let date = formatter.date(from: currentMonth) { + monthlyReturns.append(InvestmentMetrics.MonthlyReturn( + date: date, + returnPercentage: returnPercentage + )) + } + } + + return monthlyReturns + } + + // MARK: - Category Metrics + + func calculateCategoryMetrics( + for categories: [Category], + totalPortfolioValue: Decimal + ) -> [CategoryMetrics] { + categories.map { category in + let allSnapshots = category.sourcesArray.flatMap { $0.snapshotsArray } + let metrics = calculateMetrics(for: allSnapshots) + + let percentage = totalPortfolioValue > 0 + ? NSDecimalNumber(decimal: category.totalValue / totalPortfolioValue).doubleValue * 100 + : 0 + + return CategoryMetrics( + id: category.id, + categoryName: category.name, + colorHex: category.colorHex, + icon: category.icon, + totalValue: category.totalValue, + percentageOfPortfolio: percentage, + metrics: metrics + ) + } + } +} + +// MARK: - Array Extension + +extension Array where Element == Double { + func average() -> Double { + guard !isEmpty else { return 0 } + return reduce(0, +) / Double(count) + } +} diff --git a/InvestmentTracker/Services/ExportService.swift b/InvestmentTracker/Services/ExportService.swift new file mode 100644 index 0000000..5ec391a --- /dev/null +++ b/InvestmentTracker/Services/ExportService.swift @@ -0,0 +1,239 @@ +import Foundation +import UIKit + +class ExportService { + static let shared = ExportService() + + private init() {} + + // MARK: - Export Formats + + enum ExportFormat: String, CaseIterable, Identifiable { + case csv = "CSV" + case json = "JSON" + + var id: String { rawValue } + + var fileExtension: String { + switch self { + case .csv: return "csv" + case .json: return "json" + } + } + + var mimeType: String { + switch self { + case .csv: return "text/csv" + case .json: return "application/json" + } + } + } + + // MARK: - Export Data + + func exportToCSV( + sources: [InvestmentSource], + categories: [Category] + ) -> String { + var csv = "Category,Source,Date,Value (EUR),Contribution (EUR),Notes\n" + + for source in sources.sorted(by: { $0.name < $1.name }) { + let categoryName = source.category?.name ?? "Uncategorized" + + for snapshot in source.snapshotsArray { + let date = formatDate(snapshot.date) + let value = formatDecimal(snapshot.decimalValue) + let contribution = snapshot.contribution != nil + ? formatDecimal(snapshot.decimalContribution) + : "" + let notes = escapeCSV(snapshot.notes ?? "") + + csv += "\(escapeCSV(categoryName)),\(escapeCSV(source.name)),\(date),\(value),\(contribution),\(notes)\n" + } + } + + return csv + } + + func exportToJSON( + sources: [InvestmentSource], + categories: [Category] + ) -> String { + var exportData: [String: Any] = [:] + exportData["exportDate"] = ISO8601DateFormatter().string(from: Date()) + exportData["currency"] = "EUR" + + // Export categories + var categoriesArray: [[String: Any]] = [] + for category in categories { + var categoryDict: [String: Any] = [ + "id": category.id.uuidString, + "name": category.name, + "color": category.colorHex, + "icon": category.icon + ] + + // Export sources in this category + var sourcesArray: [[String: Any]] = [] + for source in category.sourcesArray { + var sourceDict: [String: Any] = [ + "id": source.id.uuidString, + "name": source.name, + "isActive": source.isActive, + "notificationFrequency": source.notificationFrequency + ] + + // Export snapshots + var snapshotsArray: [[String: Any]] = [] + for snapshot in source.snapshotsArray { + var snapshotDict: [String: Any] = [ + "id": snapshot.id.uuidString, + "date": ISO8601DateFormatter().string(from: snapshot.date), + "value": NSDecimalNumber(decimal: snapshot.decimalValue).doubleValue + ] + + if snapshot.contribution != nil { + snapshotDict["contribution"] = NSDecimalNumber( + decimal: snapshot.decimalContribution + ).doubleValue + } + + if let notes = snapshot.notes, !notes.isEmpty { + snapshotDict["notes"] = notes + } + + snapshotsArray.append(snapshotDict) + } + + sourceDict["snapshots"] = snapshotsArray + sourcesArray.append(sourceDict) + } + + categoryDict["sources"] = sourcesArray + categoriesArray.append(categoryDict) + } + + exportData["categories"] = categoriesArray + + // Add summary + let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue } + exportData["summary"] = [ + "totalSources": sources.count, + "totalCategories": categories.count, + "totalValue": NSDecimalNumber(decimal: totalValue).doubleValue, + "totalSnapshots": sources.reduce(0) { $0 + $1.snapshotCount } + ] + + // Convert to JSON + do { + let jsonData = try JSONSerialization.data( + withJSONObject: exportData, + options: [.prettyPrinted, .sortedKeys] + ) + return String(data: jsonData, encoding: .utf8) ?? "{}" + } catch { + print("JSON export error: \(error)") + return "{}" + } + } + + // MARK: - Share + + func share( + format: ExportFormat, + sources: [InvestmentSource], + categories: [Category], + from viewController: UIViewController + ) { + let content: String + let fileName: String + + switch format { + case .csv: + content = exportToCSV(sources: sources, categories: categories) + fileName = "investment_tracker_export.csv" + case .json: + content = exportToJSON(sources: sources, categories: categories) + fileName = "investment_tracker_export.json" + } + + // Create temporary file + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(fileName) + + do { + try content.write(to: tempURL, atomically: true, encoding: .utf8) + + let activityVC = UIActivityViewController( + activityItems: [tempURL], + applicationActivities: nil + ) + + // iPad support + if let popover = activityVC.popoverPresentationController { + popover.sourceView = viewController.view + popover.sourceRect = CGRect( + x: viewController.view.bounds.midX, + y: viewController.view.bounds.midY, + width: 0, + height: 0 + ) + } + + viewController.present(activityVC, animated: true) { + FirebaseService.shared.logExportAttempt( + format: format.rawValue, + success: true + ) + } + } catch { + print("Export error: \(error)") + FirebaseService.shared.logExportAttempt( + format: format.rawValue, + success: false + ) + } + } + + // MARK: - Helpers + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + private func formatDecimal(_ decimal: Decimal) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = 2 + formatter.maximumFractionDigits = 2 + formatter.decimalSeparator = "." + formatter.groupingSeparator = "" + return formatter.string(from: decimal as NSDecimalNumber) ?? "0.00" + } + + private func escapeCSV(_ value: String) -> String { + var escaped = value + if escaped.contains("\"") || escaped.contains(",") || escaped.contains("\n") { + escaped = escaped.replacingOccurrences(of: "\"", with: "\"\"") + escaped = "\"\(escaped)\"" + } + return escaped + } +} + +// MARK: - Import Service (Future) + +extension ExportService { + func importFromCSV(_ content: String) -> (sources: [ImportedSource], errors: [String]) { + // Future implementation for importing data + return ([], ["Import not yet implemented"]) + } + + struct ImportedSource { + let name: String + let categoryName: String + let snapshots: [(date: Date, value: Decimal, contribution: Decimal?, notes: String?)] + } +} diff --git a/InvestmentTracker/Services/FirebaseService.swift b/InvestmentTracker/Services/FirebaseService.swift new file mode 100644 index 0000000..c67a45c --- /dev/null +++ b/InvestmentTracker/Services/FirebaseService.swift @@ -0,0 +1,173 @@ +import Foundation +import FirebaseAnalytics + +class FirebaseService { + static let shared = FirebaseService() + + private init() {} + + // MARK: - User Properties + + func setUserTier(_ tier: UserTier) { + Analytics.setUserProperty(tier.rawValue, forName: "user_tier") + } + + enum UserTier: String { + case free = "free" + case premium = "premium" + } + + // MARK: - Screen Tracking + + func logScreenView(screenName: String, screenClass: String? = nil) { + Analytics.logEvent(AnalyticsEventScreenView, parameters: [ + AnalyticsParameterScreenName: screenName, + AnalyticsParameterScreenClass: screenClass ?? screenName + ]) + } + + // MARK: - Investment Events + + func logSourceAdded(categoryName: String, sourceCount: Int) { + Analytics.logEvent("source_added", parameters: [ + "category_name": categoryName, + "total_sources": sourceCount + ]) + } + + func logSourceDeleted(categoryName: String) { + Analytics.logEvent("source_deleted", parameters: [ + "category_name": categoryName + ]) + } + + func logSnapshotAdded(sourceName: String, value: Decimal) { + Analytics.logEvent("snapshot_added", parameters: [ + "source_name": sourceName, + "value": NSDecimalNumber(decimal: value).doubleValue + ]) + } + + func logCategoryCreated(name: String) { + Analytics.logEvent("category_created", parameters: [ + "category_name": name + ]) + } + + // MARK: - Purchase Events + + func logPaywallShown(trigger: String) { + Analytics.logEvent("paywall_shown", parameters: [ + "trigger": trigger + ]) + } + + func logPurchaseAttempt(productId: String) { + Analytics.logEvent("purchase_attempt", parameters: [ + "product_id": productId + ]) + } + + func logPurchaseSuccess(productId: String, price: Decimal, isFamilyShared: Bool) { + Analytics.logEvent(AnalyticsEventPurchase, parameters: [ + AnalyticsParameterItemID: productId, + AnalyticsParameterPrice: NSDecimalNumber(decimal: price).doubleValue, + AnalyticsParameterCurrency: "EUR", + "is_family_shared": isFamilyShared + ]) + } + + func logPurchaseFailure(productId: String, error: String) { + Analytics.logEvent("purchase_failed", parameters: [ + "product_id": productId, + "error": error + ]) + } + + func logRestorePurchases(success: Bool) { + Analytics.logEvent("restore_purchases", parameters: [ + "success": success + ]) + } + + // MARK: - Feature Usage Events + + func logChartViewed(chartType: String, isPremium: Bool) { + Analytics.logEvent("chart_viewed", parameters: [ + "chart_type": chartType, + "is_premium_chart": isPremium + ]) + } + + func logPredictionViewed(algorithm: String) { + Analytics.logEvent("prediction_viewed", parameters: [ + "algorithm": algorithm + ]) + } + + func logExportAttempt(format: String, success: Bool) { + Analytics.logEvent("export_attempt", parameters: [ + "format": format, + "success": success + ]) + } + + func logNotificationScheduled(frequency: String) { + Analytics.logEvent("notification_scheduled", parameters: [ + "frequency": frequency + ]) + } + + // MARK: - Ad Events + + func logAdImpression(adType: String) { + Analytics.logEvent("ad_impression", parameters: [ + "ad_type": adType + ]) + } + + func logAdClick(adType: String) { + Analytics.logEvent("ad_click", parameters: [ + "ad_type": adType + ]) + } + + // MARK: - Engagement Events + + func logOnboardingCompleted(stepCount: Int) { + Analytics.logEvent("onboarding_completed", parameters: [ + "steps_completed": stepCount + ]) + } + + func logWidgetUsed(widgetType: String) { + Analytics.logEvent("widget_used", parameters: [ + "widget_type": widgetType + ]) + } + + func logAppOpened(fromWidget: Bool) { + Analytics.logEvent("app_opened", parameters: [ + "from_widget": fromWidget + ]) + } + + // MARK: - Portfolio Events + + func logPortfolioMilestone(totalValue: Decimal, milestone: String) { + Analytics.logEvent("portfolio_milestone", parameters: [ + "total_value": NSDecimalNumber(decimal: totalValue).doubleValue, + "milestone": milestone + ]) + } + + // MARK: - Error Events + + func logError(type: String, message: String, context: String? = nil) { + Analytics.logEvent("app_error", parameters: [ + "error_type": type, + "error_message": message, + "context": context ?? "" + ]) + } +} diff --git a/InvestmentTracker/Services/IAPService.swift b/InvestmentTracker/Services/IAPService.swift new file mode 100644 index 0000000..594bb52 --- /dev/null +++ b/InvestmentTracker/Services/IAPService.swift @@ -0,0 +1,225 @@ +import Foundation +import StoreKit +import Combine + +@MainActor +class IAPService: ObservableObject { + // MARK: - Published Properties + + @Published private(set) var isPremium = false + @Published private(set) var products: [Product] = [] + @Published private(set) var purchaseState: PurchaseState = .idle + @Published private(set) var isFamilyShared = false + + // MARK: - Constants + + static let premiumProductID = "com.investmenttracker.premium" + static let premiumPrice = "€4.69" + + // MARK: - Private Properties + + private var updateListenerTask: Task? + private var cancellables = Set() + + // MARK: - Purchase State + + enum PurchaseState: Equatable { + case idle + case purchasing + case purchased + case failed(String) + case restored + } + + // MARK: - Initialization + + init() { + updateListenerTask = listenForTransactions() + + Task { + await loadProducts() + await updatePremiumStatus() + } + } + + deinit { + updateListenerTask?.cancel() + } + + // MARK: - Load Products + + func loadProducts() async { + do { + products = try await Product.products(for: [Self.premiumProductID]) + print("Loaded \(products.count) products") + } catch { + print("Failed to load products: \(error)") + } + } + + // MARK: - Purchase + + func purchase() async throws { + guard let product = products.first else { + throw IAPError.productNotFound + } + + purchaseState = .purchasing + + do { + let result = try await product.purchase() + + switch result { + case .success(let verification): + let transaction = try checkVerified(verification) + + // Check if family shared + isFamilyShared = transaction.ownershipType == .familyShared + + await transaction.finish() + await updatePremiumStatus() + + purchaseState = .purchased + + // Track analytics + FirebaseService.shared.logPurchaseSuccess( + productId: product.id, + price: product.price, + isFamilyShared: isFamilyShared + ) + + case .userCancelled: + purchaseState = .idle + + case .pending: + purchaseState = .idle + + @unknown default: + purchaseState = .idle + } + } catch { + purchaseState = .failed(error.localizedDescription) + FirebaseService.shared.logPurchaseFailure( + productId: product.id, + error: error.localizedDescription + ) + throw error + } + } + + // MARK: - Restore Purchases + + func restorePurchases() async { + purchaseState = .purchasing + + do { + try await AppStore.sync() + await updatePremiumStatus() + + if isPremium { + purchaseState = .restored + } else { + purchaseState = .failed("No purchases to restore") + } + } catch { + purchaseState = .failed(error.localizedDescription) + } + } + + // MARK: - Update Premium Status + + func updatePremiumStatus() async { + var isEntitled = false + var familyShared = false + + for await result in Transaction.currentEntitlements { + if case .verified(let transaction) = result { + if transaction.productID == Self.premiumProductID { + isEntitled = true + familyShared = transaction.ownershipType == .familyShared + break + } + } + } + + isPremium = isEntitled + isFamilyShared = familyShared + + // Update Core Data + let context = CoreDataStack.shared.viewContext + PremiumStatus.updateStatus( + isPremium: isEntitled, + productIdentifier: Self.premiumProductID, + transactionId: nil, + isFamilyShared: familyShared, + in: context + ) + } + + // MARK: - Transaction Listener + + private func listenForTransactions() -> Task { + return Task.detached { [weak self] in + for await result in Transaction.updates { + if case .verified(let transaction) = result { + await transaction.finish() + await self?.updatePremiumStatus() + } + } + } + } + + // MARK: - Verification + + private func checkVerified(_ result: VerificationResult) throws -> T { + switch result { + case .unverified(_, let error): + throw IAPError.verificationFailed(error.localizedDescription) + case .verified(let safe): + return safe + } + } + + // MARK: - Product Info + + var premiumProduct: Product? { + products.first { $0.id == Self.premiumProductID } + } + + var formattedPrice: String { + premiumProduct?.displayPrice ?? Self.premiumPrice + } +} + +// MARK: - IAP Error + +enum IAPError: LocalizedError { + case productNotFound + case verificationFailed(String) + case purchaseFailed(String) + + var errorDescription: String? { + switch self { + case .productNotFound: + return "Product not found. Please try again later." + case .verificationFailed(let message): + return "Verification failed: \(message)" + case .purchaseFailed(let message): + return "Purchase failed: \(message)" + } + } +} + +// MARK: - Premium Features + +extension IAPService { + static let premiumFeatures: [(icon: String, title: String, description: String)] = [ + ("infinity", "Unlimited Sources", "Track as many investments as you want"), + ("clock.arrow.circlepath", "Full History", "Access your complete investment history"), + ("chart.bar.xaxis", "Advanced Charts", "5 types of detailed analytics charts"), + ("wand.and.stars", "Predictions", "AI-powered 12-month forecasts"), + ("square.and.arrow.up", "Export Data", "Export to CSV and JSON formats"), + ("xmark.circle", "No Ads", "Ad-free experience forever"), + ("person.2", "Family Sharing", "Share with up to 5 family members") + ] +} diff --git a/InvestmentTracker/Services/NotificationService.swift b/InvestmentTracker/Services/NotificationService.swift new file mode 100644 index 0000000..255acfc --- /dev/null +++ b/InvestmentTracker/Services/NotificationService.swift @@ -0,0 +1,192 @@ +import Foundation +import UserNotifications +import UIKit + +class NotificationService: ObservableObject { + static let shared = NotificationService() + + @Published var isAuthorized = false + @Published var pendingCount = 0 + + private let center = UNUserNotificationCenter.current() + + private init() { + checkAuthorizationStatus() + } + + // MARK: - Authorization + + func checkAuthorizationStatus() { + center.getNotificationSettings { [weak self] settings in + DispatchQueue.main.async { + self?.isAuthorized = settings.authorizationStatus == .authorized + } + } + } + + func requestAuthorization() async -> Bool { + do { + let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound]) + await MainActor.run { + self.isAuthorized = granted + } + return granted + } catch { + print("Notification authorization error: \(error)") + return false + } + } + + // MARK: - Schedule Notifications + + func scheduleReminder(for source: InvestmentSource) { + guard let nextDate = source.nextReminderDate else { return } + guard source.frequency != .never else { return } + + // Remove existing notification for this source + cancelReminder(for: source) + + // Get notification time from settings + let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext) + let notificationTime = settings.defaultNotificationTime ?? defaultNotificationTime() + + // Combine date and time + let calendar = Calendar.current + var components = calendar.dateComponents([.year, .month, .day], from: nextDate) + let timeComponents = calendar.dateComponents([.hour, .minute], from: notificationTime) + components.hour = timeComponents.hour + components.minute = timeComponents.minute + + guard let triggerDate = calendar.date(from: components) else { return } + + // Create notification content + let content = UNMutableNotificationContent() + content.title = "Investment Update Reminder" + content.body = "Time to update \(source.name). Tap to add a new snapshot." + content.sound = .default + content.badge = NSNumber(value: pendingCount + 1) + content.userInfo = [ + "sourceId": source.id.uuidString, + "sourceName": source.name + ] + + // Create trigger + let triggerComponents = calendar.dateComponents( + [.year, .month, .day, .hour, .minute], + from: triggerDate + ) + let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: false) + + // Create request + let request = UNNotificationRequest( + identifier: notificationIdentifier(for: source), + content: content, + trigger: trigger + ) + + // Schedule + center.add(request) { error in + if let error = error { + print("Failed to schedule notification: \(error)") + } else { + print("Scheduled reminder for \(source.name) on \(triggerDate)") + FirebaseService.shared.logNotificationScheduled(frequency: source.notificationFrequency) + } + } + } + + func scheduleAllReminders(for sources: [InvestmentSource]) { + for source in sources { + scheduleReminder(for: source) + } + } + + // MARK: - Cancel Notifications + + func cancelReminder(for source: InvestmentSource) { + center.removePendingNotificationRequests( + withIdentifiers: [notificationIdentifier(for: source)] + ) + } + + func cancelAllReminders() { + center.removeAllPendingNotificationRequests() + } + + // MARK: - Badge Management + + func updateBadgeCount() { + let repository = InvestmentSourceRepository() + let needsUpdate = repository.fetchSourcesNeedingUpdate() + pendingCount = needsUpdate.count + + DispatchQueue.main.async { + UIApplication.shared.applicationIconBadgeNumber = self.pendingCount + } + } + + func clearBadge() { + pendingCount = 0 + DispatchQueue.main.async { + UIApplication.shared.applicationIconBadgeNumber = 0 + } + } + + // MARK: - Pending Notifications + + func getPendingNotifications() async -> [UNNotificationRequest] { + await center.pendingNotificationRequests() + } + + // MARK: - Helpers + + private func notificationIdentifier(for source: InvestmentSource) -> String { + "investment_reminder_\(source.id.uuidString)" + } + + private func defaultNotificationTime() -> Date { + var components = DateComponents() + components.hour = 9 + components.minute = 0 + return Calendar.current.date(from: components) ?? Date() + } +} + +// MARK: - Deep Link Handler + +extension NotificationService { + func handleNotificationResponse(_ response: UNNotificationResponse) { + let userInfo = response.notification.request.content.userInfo + + guard let sourceIdString = userInfo["sourceId"] as? String, + let sourceId = UUID(uuidString: sourceIdString) else { + return + } + + // Post notification for deep linking + NotificationCenter.default.post( + name: .openSourceDetail, + object: nil, + userInfo: ["sourceId": sourceId] + ) + } +} + +// MARK: - Notification Names + +extension Notification.Name { + static let openSourceDetail = Notification.Name("openSourceDetail") +} + +// MARK: - Background Refresh + +extension NotificationService { + func performBackgroundRefresh() { + updateBadgeCount() + + // Reschedule any missed notifications + let repository = InvestmentSourceRepository() + let sources = repository.fetchActiveSources() + scheduleAllReminders(for: sources) + } +} diff --git a/InvestmentTracker/Services/PredictionEngine.swift b/InvestmentTracker/Services/PredictionEngine.swift new file mode 100644 index 0000000..4f42992 --- /dev/null +++ b/InvestmentTracker/Services/PredictionEngine.swift @@ -0,0 +1,383 @@ +import Foundation + +class PredictionEngine { + static let shared = PredictionEngine() + + private let context = CoreDataStack.shared.viewContext + + private init() {} + + // MARK: - Main Prediction Interface + + func predict( + snapshots: [Snapshot], + monthsAhead: Int = 12, + algorithm: PredictionAlgorithm? = nil + ) -> PredictionResult { + guard snapshots.count >= 3 else { + return PredictionResult( + predictions: [], + algorithm: .linear, + accuracy: 0, + volatility: 0 + ) + } + + // Sort snapshots by date + let sortedSnapshots = snapshots.sorted { $0.date < $1.date } + + // Calculate volatility for algorithm selection + let volatility = calculateVolatility(snapshots: sortedSnapshots) + + // Select algorithm if not specified + let selectedAlgorithm = algorithm ?? selectBestAlgorithm(volatility: volatility) + + // Generate predictions + let predictions: [Prediction] + let accuracy: Double + + switch selectedAlgorithm { + case .linear: + predictions = predictLinear(snapshots: sortedSnapshots, monthsAhead: monthsAhead) + accuracy = calculateLinearAccuracy(snapshots: sortedSnapshots) + case .exponentialSmoothing: + predictions = predictExponentialSmoothing(snapshots: sortedSnapshots, monthsAhead: monthsAhead) + accuracy = calculateESAccuracy(snapshots: sortedSnapshots) + case .movingAverage: + predictions = predictMovingAverage(snapshots: sortedSnapshots, monthsAhead: monthsAhead) + accuracy = calculateMAAccuracy(snapshots: sortedSnapshots) + } + + return PredictionResult( + predictions: predictions, + algorithm: selectedAlgorithm, + accuracy: accuracy, + volatility: volatility + ) + } + + // MARK: - Algorithm Selection + + private func selectBestAlgorithm(volatility: Double) -> PredictionAlgorithm { + switch volatility { + case 0..<10: + return .linear // Low volatility - linear works well + case 10..<25: + return .exponentialSmoothing // Medium volatility - weight recent data + default: + return .movingAverage // High volatility - smooth out fluctuations + } + } + + // MARK: - Linear Regression + + func predictLinear(snapshots: [Snapshot], monthsAhead: Int = 12) -> [Prediction] { + guard snapshots.count >= 3 else { return [] } + + guard let firstDate = snapshots.first?.date else { return [] } + + let dataPoints: [(x: Double, y: Double)] = snapshots.map { snapshot in + let daysSinceStart = snapshot.date.timeIntervalSince(firstDate) / 86400 + return (x: daysSinceStart, y: snapshot.decimalValue.doubleValue) + } + + let (slope, intercept) = calculateLinearRegression(dataPoints: dataPoints) + let residualStdDev = calculateResidualStdDev(dataPoints: dataPoints, slope: slope, intercept: intercept) + + var predictions: [Prediction] = [] + let lastDate = snapshots.last!.date + + for month in 1...monthsAhead { + guard let futureDate = Calendar.current.date( + byAdding: .month, + value: month, + to: lastDate + ) else { continue } + + let daysFromStart = futureDate.timeIntervalSince(firstDate) / 86400 + let predictedValue = max(0, slope * daysFromStart + intercept) + + // Widen confidence interval for further predictions + let confidenceMultiplier = 1.0 + (Double(month) * 0.02) + let intervalWidth = residualStdDev * 1.96 * confidenceMultiplier + + predictions.append(Prediction( + date: futureDate, + predictedValue: Decimal(predictedValue), + algorithm: .linear, + confidenceInterval: Prediction.ConfidenceInterval( + lower: Decimal(max(0, predictedValue - intervalWidth)), + upper: Decimal(predictedValue + intervalWidth) + ) + )) + } + + return predictions + } + + private func calculateLinearRegression( + dataPoints: [(x: Double, y: Double)] + ) -> (slope: Double, intercept: Double) { + let n = Double(dataPoints.count) + let sumX = dataPoints.reduce(0) { $0 + $1.x } + let sumY = dataPoints.reduce(0) { $0 + $1.y } + let sumXY = dataPoints.reduce(0) { $0 + ($1.x * $1.y) } + let sumX2 = dataPoints.reduce(0) { $0 + ($1.x * $1.x) } + + let denominator = n * sumX2 - sumX * sumX + guard denominator != 0 else { return (0, sumY / n) } + + let slope = (n * sumXY - sumX * sumY) / denominator + let intercept = (sumY - slope * sumX) / n + + return (slope, intercept) + } + + private func calculateResidualStdDev( + dataPoints: [(x: Double, y: Double)], + slope: Double, + intercept: Double + ) -> Double { + guard dataPoints.count > 2 else { return 0 } + + let residuals = dataPoints.map { point in + let predicted = slope * point.x + intercept + return pow(point.y - predicted, 2) + } + + let meanSquaredError = residuals.reduce(0, +) / Double(dataPoints.count - 2) + return sqrt(meanSquaredError) + } + + private func calculateLinearAccuracy(snapshots: [Snapshot]) -> Double { + guard snapshots.count >= 5 else { return 0.5 } + + // Use last 20% of data for validation + let splitIndex = Int(Double(snapshots.count) * 0.8) + let trainingData = Array(snapshots.prefix(splitIndex)) + let validationData = Array(snapshots.suffix(from: splitIndex)) + + guard let firstDate = trainingData.first?.date else { return 0.5 } + + let trainPoints = trainingData.map { snapshot in + (x: snapshot.date.timeIntervalSince(firstDate) / 86400, y: snapshot.decimalValue.doubleValue) + } + + let (slope, intercept) = calculateLinearRegression(dataPoints: trainPoints) + + // Calculate R-squared on validation data + let validationValues = validationData.map { $0.decimalValue.doubleValue } + let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count) + + var ssRes: Double = 0 + var ssTot: Double = 0 + + for snapshot in validationData { + let x = snapshot.date.timeIntervalSince(firstDate) / 86400 + let actual = snapshot.decimalValue.doubleValue + let predicted = slope * x + intercept + + ssRes += pow(actual - predicted, 2) + ssTot += pow(actual - meanValidation, 2) + } + + guard ssTot != 0 else { return 0.5 } + let rSquared = max(0, 1 - (ssRes / ssTot)) + + return min(1.0, rSquared) + } + + // MARK: - Exponential Smoothing + + func predictExponentialSmoothing( + snapshots: [Snapshot], + monthsAhead: Int = 12, + alpha: Double = 0.3 + ) -> [Prediction] { + guard snapshots.count >= 3 else { return [] } + + let values = snapshots.map { $0.decimalValue.doubleValue } + + // Calculate smoothed values + var smoothed = values[0] + for i in 1..= 2 { + let recentChange = values.suffix(3).reduce(0) { $0 + $1 } / 3.0 - + values.prefix(3).reduce(0) { $0 + $1 } / 3.0 + trend = recentChange / Double(values.count) + } + + // Calculate standard deviation for confidence interval + let stdDev = calculateStdDev(values: values) + + var predictions: [Prediction] = [] + let lastDate = snapshots.last!.date + + for month in 1...monthsAhead { + guard let futureDate = Calendar.current.date( + byAdding: .month, + value: month, + to: lastDate + ) else { continue } + + let predictedValue = max(0, smoothed + trend * Double(month)) + let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.05) + + predictions.append(Prediction( + date: futureDate, + predictedValue: Decimal(predictedValue), + algorithm: .exponentialSmoothing, + confidenceInterval: Prediction.ConfidenceInterval( + lower: Decimal(max(0, predictedValue - intervalWidth)), + upper: Decimal(predictedValue + intervalWidth) + ) + )) + } + + return predictions + } + + private func calculateESAccuracy(snapshots: [Snapshot]) -> Double { + guard snapshots.count >= 5 else { return 0.5 } + + let values = snapshots.map { $0.decimalValue.doubleValue } + let splitIndex = Int(Double(values.count) * 0.8) + + var smoothed = values[0] + for i in 1.. [Prediction] { + guard snapshots.count >= windowSize else { return [] } + + let values = snapshots.map { $0.decimalValue.doubleValue } + + // Calculate moving average of last window + let recentValues = Array(values.suffix(windowSize)) + let movingAverage = recentValues.reduce(0, +) / Double(windowSize) + + // Calculate average monthly change + var changes: [Double] = [] + for i in 1.. Double { + guard snapshots.count >= 5 else { return 0.5 } + + let values = snapshots.map { $0.decimalValue.doubleValue } + let windowSize = 3 + let splitIndex = Int(Double(values.count) * 0.8) + + guard splitIndex > windowSize else { return 0.5 } + + let recentWindow = Array(values[(splitIndex - windowSize).. Double { + let values = snapshots.map { $0.decimalValue.doubleValue } + guard values.count >= 2 else { return 0 } + + var returns: [Double] = [] + for i in 1.. Double { + guard values.count >= 2 else { return 0 } + + let mean = values.reduce(0, +) / Double(values.count) + let squaredDifferences = values.map { pow($0 - mean, 2) } + let variance = squaredDifferences.reduce(0, +) / Double(values.count - 1) + + return sqrt(variance) + } +} + +// MARK: - Decimal Extension + +private extension Decimal { + var doubleValue: Double { + NSDecimalNumber(decimal: self).doubleValue + } +} diff --git a/InvestmentTracker/Utilities/Constants/AppConstants.swift b/InvestmentTracker/Utilities/Constants/AppConstants.swift new file mode 100644 index 0000000..43d63c8 --- /dev/null +++ b/InvestmentTracker/Utilities/Constants/AppConstants.swift @@ -0,0 +1,175 @@ +import Foundation + +enum AppConstants { + // MARK: - App Info + + static let appName = "Investment Tracker" + static let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + static let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" + + // MARK: - Bundle Identifiers + + static let bundleIdentifier = "com.yourteam.investmenttracker" + static let appGroupIdentifier = "group.com.yourteam.investmenttracker" + static let cloudKitContainerIdentifier = "iCloud.com.yourteam.investmenttracker" + + // MARK: - StoreKit + + static let premiumProductID = "com.investmenttracker.premium" + static let premiumPrice = "€4.69" + + // MARK: - AdMob + + #if DEBUG + static let adMobAppID = "ca-app-pub-3940256099942544~1458002511" // Test App ID + static let bannerAdUnitID = "ca-app-pub-3940256099942544/2934735716" // Test Banner + #else + static let adMobAppID = "ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY" // Replace with real App ID + static let bannerAdUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/YYYYYYYYYY" // Replace with real Ad Unit ID + #endif + + // MARK: - Currency + + static let defaultCurrency = "EUR" + static let currencySymbol = "€" + + // MARK: - Freemium Limits + + static let maxFreeSources = 5 + static let maxFreeHistoricalMonths = 12 + + // MARK: - UI Constants + + enum UI { + static let cornerRadius: CGFloat = 12 + static let smallCornerRadius: CGFloat = 8 + static let largeCornerRadius: CGFloat = 16 + + static let padding: CGFloat = 16 + static let smallPadding: CGFloat = 8 + static let largePadding: CGFloat = 24 + + static let iconSize: CGFloat = 24 + static let smallIconSize: CGFloat = 16 + static let largeIconSize: CGFloat = 32 + + static let bannerAdHeight: CGFloat = 50 + static let tabBarHeight: CGFloat = 49 + + static let cardShadowRadius: CGFloat = 4 + static let cardShadowOpacity: CGFloat = 0.1 + } + + // MARK: - Animation + + enum Animation { + static let defaultDuration: Double = 0.3 + static let shortDuration: Double = 0.15 + static let longDuration: Double = 0.5 + } + + // MARK: - Charts + + enum Charts { + static let defaultMonthsToShow = 12 + static let predictionMonths = 12 + static let minDataPointsForPrediction = 3 + static let confidenceIntervalPercentage = 0.15 + } + + // MARK: - Notifications + + enum Notifications { + static let defaultHour = 9 + static let defaultMinute = 0 + static let categoryIdentifier = "INVESTMENT_REMINDER" + } + + // MARK: - Storage Keys + + enum StorageKeys { + static let onboardingCompleted = "onboardingCompleted" + static let adConsentObtained = "adConsentObtained" + static let lastSyncDate = "lastSyncDate" + static let selectedCategoryFilter = "selectedCategoryFilter" + static let preferredChartType = "preferredChartType" + } + + // MARK: - Deep Links + + enum DeepLinks { + static let scheme = "investmenttracker" + static let sourceDetail = "source" + static let addSnapshot = "addSnapshot" + static let premium = "premium" + } + + // MARK: - URLs + + enum URLs { + static let privacyPolicy = "https://yourwebsite.com/privacy" + static let termsOfService = "https://yourwebsite.com/terms" + static let support = "https://yourwebsite.com/support" + static let appStore = "https://apps.apple.com/app/idXXXXXXXXXX" + } + + // MARK: - Feature Flags + + enum Features { + static let enablePredictions = true + static let enableExport = true + static let enableWidgets = true + static let enableNotifications = true + static let enableAnalytics = true + } +} + +// MARK: - SF Symbols + +enum SFSymbol { + // Navigation + static let dashboard = "chart.pie.fill" + static let sources = "list.bullet" + static let charts = "chart.xyaxis.line" + static let settings = "gearshape.fill" + + // Actions + static let add = "plus" + static let edit = "pencil" + static let delete = "trash" + static let share = "square.and.arrow.up" + static let export = "arrow.up.doc" + + // Status + static let checkmark = "checkmark.circle.fill" + static let warning = "exclamationmark.triangle.fill" + static let error = "xmark.circle.fill" + static let info = "info.circle.fill" + + // Financial + static let trendUp = "arrow.up.right" + static let trendDown = "arrow.down.right" + static let money = "eurosign.circle.fill" + static let chart = "chart.line.uptrend.xyaxis" + + // Categories + static let stocks = "chart.line.uptrend.xyaxis" + static let bonds = "building.columns.fill" + static let realEstate = "house.fill" + static let crypto = "bitcoinsign.circle.fill" + static let cash = "banknote.fill" + static let etf = "chart.bar.fill" + static let retirement = "person.fill" + static let other = "ellipsis.circle.fill" + + // Premium + static let premium = "crown.fill" + static let lock = "lock.fill" + static let unlock = "lock.open.fill" + + // Misc + static let calendar = "calendar" + static let notification = "bell.fill" + static let refresh = "arrow.clockwise" + static let close = "xmark" +} diff --git a/InvestmentTracker/Utilities/Extensions/Color+Extensions.swift b/InvestmentTracker/Utilities/Extensions/Color+Extensions.swift new file mode 100644 index 0000000..f5ea093 --- /dev/null +++ b/InvestmentTracker/Utilities/Extensions/Color+Extensions.swift @@ -0,0 +1,166 @@ +import SwiftUI + +extension Color { + // MARK: - Hex Initialization + + init?(hex: String) { + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") + + var rgb: UInt64 = 0 + + guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { + return nil + } + + let length = hexSanitized.count + + switch length { + case 6: + self.init( + red: Double((rgb & 0xFF0000) >> 16) / 255.0, + green: Double((rgb & 0x00FF00) >> 8) / 255.0, + blue: Double(rgb & 0x0000FF) / 255.0 + ) + case 8: + self.init( + red: Double((rgb & 0xFF000000) >> 24) / 255.0, + green: Double((rgb & 0x00FF0000) >> 16) / 255.0, + blue: Double((rgb & 0x0000FF00) >> 8) / 255.0, + opacity: Double(rgb & 0x000000FF) / 255.0 + ) + default: + return nil + } + } + + // MARK: - Hex String Output + + var hexString: String { + guard let components = UIColor(self).cgColor.components else { + return "#000000" + } + + let r = Int(components[0] * 255.0) + let g = Int(components[1] * 255.0) + let b = Int(components[2] * 255.0) + + return String(format: "#%02X%02X%02X", r, g, b) + } + + // MARK: - App Colors + + static let appPrimary = Color(hex: "#3B82F6") ?? .blue + static let appSecondary = Color(hex: "#10B981") ?? .green + static let appAccent = Color(hex: "#F59E0B") ?? .orange + static let appError = Color(hex: "#EF4444") ?? .red + static let appSuccess = Color(hex: "#10B981") ?? .green + static let appWarning = Color(hex: "#F59E0B") ?? .orange + + // MARK: - Financial Colors + + static let positiveGreen = Color(hex: "#10B981") ?? .green + static let negativeRed = Color(hex: "#EF4444") ?? .red + static let neutralGray = Color(hex: "#6B7280") ?? .gray + + static func financialColor(for value: Decimal) -> Color { + if value > 0 { + return .positiveGreen + } else if value < 0 { + return .negativeRed + } else { + return .neutralGray + } + } + + static func financialColor(for value: Double) -> Color { + if value > 0 { + return .positiveGreen + } else if value < 0 { + return .negativeRed + } else { + return .neutralGray + } + } + + // MARK: - Category Colors + + static let categoryColors: [String] = [ + "#3B82F6", // Blue + "#10B981", // Green + "#F59E0B", // Amber + "#EF4444", // Red + "#8B5CF6", // Purple + "#EC4899", // Pink + "#14B8A6", // Teal + "#F97316", // Orange + "#6366F1", // Indigo + "#84CC16", // Lime + "#06B6D4", // Cyan + "#A855F7" // Violet + ] + + static func categoryColor(at index: Int) -> Color { + let hex = categoryColors[index % categoryColors.count] + return Color(hex: hex) ?? .blue + } + + // MARK: - Chart Colors + + static let chartColors: [Color] = categoryColors.compactMap { Color(hex: $0) } + + // MARK: - Adjustments + + func lighter(by percentage: Double = 0.2) -> Color { + adjustBrightness(by: abs(percentage)) + } + + func darker(by percentage: Double = 0.2) -> Color { + adjustBrightness(by: -abs(percentage)) + } + + private func adjustBrightness(by percentage: Double) -> Color { + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + + UIColor(self).getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + + let newBrightness = max(0, min(1, brightness + CGFloat(percentage))) + + return Color(UIColor(hue: hue, saturation: saturation, brightness: newBrightness, alpha: alpha)) + } + + // MARK: - Opacity Variants + + var soft: Color { + self.opacity(0.1) + } + + var medium: Color { + self.opacity(0.5) + } +} + +// MARK: - Gradient Extensions + +extension LinearGradient { + static let appPrimaryGradient = LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.lighter()], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + static let positiveGradient = LinearGradient( + colors: [Color.positiveGreen.lighter(), Color.positiveGreen], + startPoint: .top, + endPoint: .bottom + ) + + static let negativeGradient = LinearGradient( + colors: [Color.negativeRed.lighter(), Color.negativeRed], + startPoint: .top, + endPoint: .bottom + ) +} diff --git a/InvestmentTracker/Utilities/Extensions/Date+Extensions.swift b/InvestmentTracker/Utilities/Extensions/Date+Extensions.swift new file mode 100644 index 0000000..102ebc7 --- /dev/null +++ b/InvestmentTracker/Utilities/Extensions/Date+Extensions.swift @@ -0,0 +1,195 @@ +import Foundation + +extension Date { + // MARK: - Formatting + + var shortDateString: String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .none + return formatter.string(from: self) + } + + var mediumDateString: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: self) + } + + var longDateString: String { + let formatter = DateFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .none + return formatter.string(from: self) + } + + var monthYearString: String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM yyyy" + return formatter.string(from: self) + } + + var yearString: String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy" + return formatter.string(from: self) + } + + var iso8601String: String { + ISO8601DateFormatter().string(from: self) + } + + // MARK: - Components + + var startOfDay: Date { + Calendar.current.startOfDay(for: self) + } + + var startOfMonth: Date { + let components = Calendar.current.dateComponents([.year, .month], from: self) + return Calendar.current.date(from: components) ?? self + } + + var startOfYear: Date { + let components = Calendar.current.dateComponents([.year], from: self) + return Calendar.current.date(from: components) ?? self + } + + var endOfMonth: Date { + let startOfNextMonth = Calendar.current.date( + byAdding: .month, + value: 1, + to: startOfMonth + ) ?? self + return Calendar.current.date(byAdding: .day, value: -1, to: startOfNextMonth) ?? self + } + + // MARK: - Comparisons + + func isSameDay(as other: Date) -> Bool { + Calendar.current.isDate(self, inSameDayAs: other) + } + + func isSameMonth(as other: Date) -> Bool { + let selfComponents = Calendar.current.dateComponents([.year, .month], from: self) + let otherComponents = Calendar.current.dateComponents([.year, .month], from: other) + return selfComponents.year == otherComponents.year && + selfComponents.month == otherComponents.month + } + + func isSameYear(as other: Date) -> Bool { + Calendar.current.component(.year, from: self) == + Calendar.current.component(.year, from: other) + } + + var isToday: Bool { + Calendar.current.isDateInToday(self) + } + + var isYesterday: Bool { + Calendar.current.isDateInYesterday(self) + } + + var isThisMonth: Bool { + isSameMonth(as: Date()) + } + + var isThisYear: Bool { + isSameYear(as: Date()) + } + + // MARK: - Calculations + + func adding(days: Int) -> Date { + Calendar.current.date(byAdding: .day, value: days, to: self) ?? self + } + + func adding(months: Int) -> Date { + Calendar.current.date(byAdding: .month, value: months, to: self) ?? self + } + + func adding(years: Int) -> Date { + Calendar.current.date(byAdding: .year, value: years, to: self) ?? self + } + + func monthsBetween(_ other: Date) -> Int { + let components = Calendar.current.dateComponents([.month], from: self, to: other) + return components.month ?? 0 + } + + func daysBetween(_ other: Date) -> Int { + let components = Calendar.current.dateComponents([.day], from: self, to: other) + return components.day ?? 0 + } + + func yearsBetween(_ other: Date) -> Double { + let days = Double(daysBetween(other)) + return days / 365.25 + } + + // MARK: - Relative Description + + var relativeDescription: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: self, relativeTo: Date()) + } + + var friendlyDescription: String { + if isToday { + return "Today" + } else if isYesterday { + return "Yesterday" + } else if isThisMonth { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, d" + return formatter.string(from: self) + } else if isThisYear { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d" + return formatter.string(from: self) + } else { + return mediumDateString + } + } +} + +// MARK: - Date Range + +struct DateRange { + let start: Date + let end: Date + + static var thisMonth: DateRange { + let now = Date() + return DateRange(start: now.startOfMonth, end: now) + } + + static var lastMonth: DateRange { + let now = Date() + let lastMonth = now.adding(months: -1) + return DateRange(start: lastMonth.startOfMonth, end: lastMonth.endOfMonth) + } + + static var thisYear: DateRange { + let now = Date() + return DateRange(start: now.startOfYear, end: now) + } + + static var lastYear: DateRange { + let now = Date() + let lastYear = now.adding(years: -1) + return DateRange(start: lastYear.startOfYear, end: now.startOfYear.adding(days: -1)) + } + + static func last(months: Int) -> DateRange { + let now = Date() + let start = now.adding(months: -months) + return DateRange(start: start, end: now) + } + + func contains(_ date: Date) -> Bool { + date >= start && date <= end + } +} diff --git a/InvestmentTracker/Utilities/Extensions/Decimal+Extensions.swift b/InvestmentTracker/Utilities/Extensions/Decimal+Extensions.swift new file mode 100644 index 0000000..011f5d6 --- /dev/null +++ b/InvestmentTracker/Utilities/Extensions/Decimal+Extensions.swift @@ -0,0 +1,127 @@ +import Foundation + +extension Decimal { + // MARK: - Formatting + + var currencyString: String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "EUR" + formatter.maximumFractionDigits = 2 + return formatter.string(from: self as NSDecimalNumber) ?? "€0.00" + } + + var compactCurrencyString: String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "EUR" + formatter.maximumFractionDigits = 0 + return formatter.string(from: self as NSDecimalNumber) ?? "€0" + } + + var shortCurrencyString: String { + let value = NSDecimalNumber(decimal: self).doubleValue + + switch abs(value) { + case 1_000_000...: + return String(format: "€%.1fM", value / 1_000_000) + case 1_000...: + return String(format: "€%.1fK", value / 1_000) + default: + return compactCurrencyString + } + } + + var percentageString: String { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + formatter.maximumFractionDigits = 2 + formatter.multiplier = 1 + return formatter.string(from: self as NSDecimalNumber) ?? "0%" + } + + var signedPercentageString: String { + let prefix = self >= 0 ? "+" : "" + return prefix + percentageString + } + + var decimalString: String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 2 + return formatter.string(from: self as NSDecimalNumber) ?? "0" + } + + // MARK: - Conversions + + var doubleValue: Double { + NSDecimalNumber(decimal: self).doubleValue + } + + var intValue: Int { + NSDecimalNumber(decimal: self).intValue + } + + // MARK: - Math Operations + + var abs: Decimal { + self < 0 ? -self : self + } + + func rounded(scale: Int = 2) -> Decimal { + var result = Decimal() + var mutableSelf = self + NSDecimalRound(&result, &mutableSelf, scale, .plain) + return result + } + + // MARK: - Comparisons + + var isPositive: Bool { + self > 0 + } + + var isNegative: Bool { + self < 0 + } + + var isZero: Bool { + self == 0 + } + + // MARK: - Static Helpers + + static func from(_ double: Double) -> Decimal { + Decimal(double) + } + + static func from(_ string: String) -> Decimal? { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.number(from: string)?.decimalValue + } +} + +// MARK: - NSDecimalNumber Extension + +extension NSDecimalNumber { + var currencyString: String { + decimalValue.currencyString + } + + var compactCurrencyString: String { + decimalValue.compactCurrencyString + } +} + +// MARK: - Optional Decimal + +extension Optional where Wrapped == Decimal { + var orZero: Decimal { + self ?? Decimal.zero + } + + var currencyString: String { + (self ?? Decimal.zero).currencyString + } +} diff --git a/InvestmentTracker/Utilities/FreemiumValidator.swift b/InvestmentTracker/Utilities/FreemiumValidator.swift new file mode 100644 index 0000000..290c2ea --- /dev/null +++ b/InvestmentTracker/Utilities/FreemiumValidator.swift @@ -0,0 +1,186 @@ +import Foundation + +enum FreemiumLimits { + static let maxSources = 5 + static let maxHistoricalMonths = 12 +} + +class FreemiumValidator: ObservableObject { + private let iapService: IAPService + + init(iapService: IAPService) { + self.iapService = iapService + } + + // MARK: - Source Limits + + var isPremium: Bool { + iapService.isPremium + } + + func canAddSource(currentCount: Int) -> Bool { + if iapService.isPremium { return true } + return currentCount < FreemiumLimits.maxSources + } + + func remainingSources(currentCount: Int) -> Int { + if iapService.isPremium { return Int.max } + return max(0, FreemiumLimits.maxSources - currentCount) + } + + var sourceLimit: Int { + iapService.isPremium ? Int.max : FreemiumLimits.maxSources + } + + var sourceLimitDescription: String { + if iapService.isPremium { + return "Unlimited" + } + return "\(FreemiumLimits.maxSources) sources" + } + + // MARK: - Historical Data Limits + + func filterSnapshots(_ snapshots: [Snapshot]) -> [Snapshot] { + if iapService.isPremium { return snapshots } + + let cutoffDate = Calendar.current.date( + byAdding: .month, + value: -FreemiumLimits.maxHistoricalMonths, + to: Date() + ) ?? Date() + + return snapshots.filter { $0.date >= cutoffDate } + } + + func isSnapshotAccessible(_ snapshot: Snapshot) -> Bool { + if iapService.isPremium { return true } + + let cutoffDate = Calendar.current.date( + byAdding: .month, + value: -FreemiumLimits.maxHistoricalMonths, + to: Date() + ) ?? Date() + + return snapshot.date >= cutoffDate + } + + var historicalLimit: Int { + iapService.isPremium ? Int.max : FreemiumLimits.maxHistoricalMonths + } + + var historicalLimitDescription: String { + if iapService.isPremium { + return "Full history" + } + return "Last \(FreemiumLimits.maxHistoricalMonths) months" + } + + // MARK: - Feature Access + + func canExport() -> Bool { + return iapService.isPremium + } + + func canViewPredictions() -> Bool { + return iapService.isPremium + } + + func canViewAdvancedCharts() -> Bool { + return iapService.isPremium + } + + func canAccessFeature(_ feature: PremiumFeature) -> Bool { + if iapService.isPremium { return true } + + switch feature { + case .unlimitedSources: + return false + case .fullHistory: + return false + case .advancedCharts: + return false + case .predictions: + return false + case .export: + return false + case .noAds: + return false + } + } + + // MARK: - Premium Features Enum + + enum PremiumFeature: String, CaseIterable, Identifiable { + case unlimitedSources = "unlimited_sources" + case fullHistory = "full_history" + case advancedCharts = "advanced_charts" + case predictions = "predictions" + case export = "export" + case noAds = "no_ads" + + var id: String { rawValue } + + var title: String { + switch self { + case .unlimitedSources: return "Unlimited Sources" + case .fullHistory: return "Full History" + case .advancedCharts: return "Advanced Charts" + case .predictions: return "Predictions" + case .export: return "Export Data" + case .noAds: return "No Ads" + } + } + + var icon: String { + switch self { + case .unlimitedSources: return "infinity" + case .fullHistory: return "clock.arrow.circlepath" + case .advancedCharts: return "chart.bar.xaxis" + case .predictions: return "wand.and.stars" + case .export: return "square.and.arrow.up" + case .noAds: return "xmark.circle" + } + } + + var description: String { + switch self { + case .unlimitedSources: + return "Track as many investment sources as you want" + case .fullHistory: + return "Access your complete investment history" + case .advancedCharts: + return "5 types of detailed analytics charts" + case .predictions: + return "AI-powered 12-month forecasts" + case .export: + return "Export to CSV and JSON formats" + case .noAds: + return "Ad-free experience forever" + } + } + } + + // MARK: - Paywall Triggers + + enum PaywallTrigger: String { + case sourceLimit = "source_limit" + case historyLimit = "history_limit" + case advancedCharts = "advanced_charts" + case predictions = "predictions" + case export = "export" + case settingsUpgrade = "settings_upgrade" + case manualTap = "manual_tap" + } + + func shouldShowPaywall(for trigger: PaywallTrigger) -> Bool { + guard !iapService.isPremium else { return false } + + switch trigger { + case .sourceLimit, .historyLimit, .advancedCharts, .predictions, .export: + return true + case .settingsUpgrade, .manualTap: + return true + } + } +} diff --git a/InvestmentTracker/ViewModels/ChartsViewModel.swift b/InvestmentTracker/ViewModels/ChartsViewModel.swift new file mode 100644 index 0000000..2b7b6bb --- /dev/null +++ b/InvestmentTracker/ViewModels/ChartsViewModel.swift @@ -0,0 +1,329 @@ +import Foundation +import Combine + +@MainActor +class ChartsViewModel: ObservableObject { + // MARK: - Chart Types + + enum ChartType: String, CaseIterable, Identifiable { + case evolution = "Evolution" + case allocation = "Allocation" + case performance = "Performance" + case drawdown = "Drawdown" + case volatility = "Volatility" + case prediction = "Prediction" + + var id: String { rawValue } + + var icon: String { + switch self { + case .evolution: return "chart.line.uptrend.xyaxis" + case .allocation: return "chart.pie.fill" + case .performance: return "chart.bar.fill" + case .drawdown: return "arrow.down.right.circle" + case .volatility: return "waveform.path.ecg" + case .prediction: return "wand.and.stars" + } + } + + var isPremium: Bool { + switch self { + case .evolution: + return false + case .allocation, .performance, .drawdown, .volatility, .prediction: + return true + } + } + + var description: String { + switch self { + case .evolution: + return "Track your portfolio value over time" + case .allocation: + return "See how your investments are distributed" + case .performance: + return "Compare returns across categories" + case .drawdown: + return "Analyze declines from peak values" + case .volatility: + return "Understand investment risk levels" + case .prediction: + return "View 12-month forecasts" + } + } + } + + // MARK: - Published Properties + + @Published var selectedChartType: ChartType = .evolution + @Published var selectedCategory: Category? + @Published var selectedTimeRange: TimeRange = .year + + @Published var evolutionData: [(date: Date, value: Decimal)] = [] + @Published var allocationData: [(category: String, value: Decimal, color: String)] = [] + @Published var performanceData: [(category: String, cagr: Double, color: String)] = [] + @Published var drawdownData: [(date: Date, drawdown: Double)] = [] + @Published var volatilityData: [(date: Date, volatility: Double)] = [] + @Published var predictionData: [Prediction] = [] + + @Published var isLoading = false + @Published var showingPaywall = false + + // MARK: - Time Range + + enum TimeRange: String, CaseIterable, Identifiable { + case month = "1M" + case quarter = "3M" + case halfYear = "6M" + case year = "1Y" + case all = "All" + + var id: String { rawValue } + + var months: Int? { + switch self { + case .month: return 1 + case .quarter: return 3 + case .halfYear: return 6 + case .year: return 12 + case .all: return nil + } + } + } + + // MARK: - Dependencies + + private let sourceRepository: InvestmentSourceRepository + private let categoryRepository: CategoryRepository + private let snapshotRepository: SnapshotRepository + private let calculationService: CalculationService + private let predictionEngine: PredictionEngine + private let freemiumValidator: FreemiumValidator + private var cancellables = Set() + + // MARK: - Initialization + + init( + sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(), + categoryRepository: CategoryRepository = CategoryRepository(), + snapshotRepository: SnapshotRepository = SnapshotRepository(), + calculationService: CalculationService = .shared, + predictionEngine: PredictionEngine = .shared, + iapService: IAPService + ) { + self.sourceRepository = sourceRepository + self.categoryRepository = categoryRepository + self.snapshotRepository = snapshotRepository + self.calculationService = calculationService + self.predictionEngine = predictionEngine + self.freemiumValidator = FreemiumValidator(iapService: iapService) + + setupObservers() + loadData() + } + + // MARK: - Setup + + private func setupObservers() { + Publishers.CombineLatest3($selectedChartType, $selectedCategory, $selectedTimeRange) + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) + .sink { [weak self] chartType, category, timeRange in + self?.updateChartData(chartType: chartType, category: category, timeRange: timeRange) + } + .store(in: &cancellables) + } + + // MARK: - Data Loading + + func loadData() { + updateChartData( + chartType: selectedChartType, + category: selectedCategory, + timeRange: selectedTimeRange + ) + + FirebaseService.shared.logScreenView(screenName: "Charts") + } + + func selectChart(_ chartType: ChartType) { + if chartType.isPremium && !freemiumValidator.isPremium { + showingPaywall = true + FirebaseService.shared.logPaywallShown(trigger: "advanced_charts") + return + } + + selectedChartType = chartType + FirebaseService.shared.logChartViewed( + chartType: chartType.rawValue, + isPremium: chartType.isPremium + ) + } + + private func updateChartData(chartType: ChartType, category: Category?, timeRange: TimeRange) { + isLoading = true + + let sources: [InvestmentSource] + if let category = category { + sources = sourceRepository.fetchSources(for: category) + } else { + sources = sourceRepository.sources + } + + var snapshots = sources.flatMap { $0.snapshotsArray } + + // Filter by time range + if let months = timeRange.months { + let cutoff = Calendar.current.date(byAdding: .month, value: -months, to: Date()) ?? Date() + snapshots = snapshots.filter { $0.date >= cutoff } + } + + // Apply freemium filter + snapshots = freemiumValidator.filterSnapshots(snapshots) + + switch chartType { + case .evolution: + calculateEvolutionData(from: snapshots) + case .allocation: + calculateAllocationData() + case .performance: + calculatePerformanceData() + case .drawdown: + calculateDrawdownData(from: snapshots) + case .volatility: + calculateVolatilityData(from: snapshots) + case .prediction: + calculatePredictionData(from: snapshots) + } + + isLoading = false + } + + // MARK: - Chart Calculations + + private func calculateEvolutionData(from snapshots: [Snapshot]) { + let sortedSnapshots = snapshots.sorted { $0.date < $1.date } + + var dateValues: [Date: Decimal] = [:] + + for snapshot in sortedSnapshots { + let day = Calendar.current.startOfDay(for: snapshot.date) + dateValues[day, default: 0] += snapshot.decimalValue + } + + evolutionData = dateValues + .map { (date: $0.key, value: $0.value) } + .sorted { $0.date < $1.date } + } + + private func calculateAllocationData() { + let categories = categoryRepository.categories + let total = categories.reduce(Decimal.zero) { $0 + $1.totalValue } + + allocationData = categories + .filter { $0.totalValue > 0 } + .map { category in + ( + category: category.name, + value: category.totalValue, + color: category.colorHex + ) + } + .sorted { $0.value > $1.value } + } + + private func calculatePerformanceData() { + let categories = categoryRepository.categories + + performanceData = categories.compactMap { category in + let snapshots = category.sourcesArray.flatMap { $0.snapshotsArray } + guard snapshots.count >= 2 else { return nil } + + let metrics = calculationService.calculateMetrics(for: snapshots) + + return ( + category: category.name, + cagr: metrics.cagr, + color: category.colorHex + ) + }.sorted { $0.cagr > $1.cagr } + } + + private func calculateDrawdownData(from snapshots: [Snapshot]) { + let sortedSnapshots = snapshots.sorted { $0.date < $1.date } + guard !sortedSnapshots.isEmpty else { + drawdownData = [] + return + } + + var peak = sortedSnapshots.first!.decimalValue + var data: [(date: Date, drawdown: Double)] = [] + + for snapshot in sortedSnapshots { + let value = snapshot.decimalValue + if value > peak { + peak = value + } + + let drawdown = peak > 0 + ? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100 + : 0 + + data.append((date: snapshot.date, drawdown: -drawdown)) + } + + drawdownData = data + } + + private func calculateVolatilityData(from snapshots: [Snapshot]) { + let sortedSnapshots = snapshots.sorted { $0.date < $1.date } + guard sortedSnapshots.count >= 3 else { + volatilityData = [] + return + } + + var data: [(date: Date, volatility: Double)] = [] + let windowSize = 3 + + for i in windowSize.. 0 ? (stdDev / mean) * 100 : 0 + + data.append((date: sortedSnapshots[i].date, volatility: volatility)) + } + + volatilityData = data + } + + private func calculatePredictionData(from snapshots: [Snapshot]) { + guard freemiumValidator.canViewPredictions() else { + predictionData = [] + return + } + + let result = predictionEngine.predict(snapshots: snapshots) + predictionData = result.predictions + } + + // MARK: - Computed Properties + + var categories: [Category] { + categoryRepository.categories + } + + var availableChartTypes: [ChartType] { + ChartType.allCases + } + + var isPremium: Bool { + freemiumValidator.isPremium + } + + var hasData: Bool { + !sourceRepository.sources.isEmpty + } +} diff --git a/InvestmentTracker/ViewModels/DashboardViewModel.swift b/InvestmentTracker/ViewModels/DashboardViewModel.swift new file mode 100644 index 0000000..3bc0697 --- /dev/null +++ b/InvestmentTracker/ViewModels/DashboardViewModel.swift @@ -0,0 +1,203 @@ +import Foundation +import Combine +import CoreData + +@MainActor +class DashboardViewModel: ObservableObject { + // MARK: - Published Properties + + @Published var portfolioSummary: PortfolioSummary = .empty + @Published var categoryMetrics: [CategoryMetrics] = [] + @Published var recentSnapshots: [Snapshot] = [] + @Published var sourcesNeedingUpdate: [InvestmentSource] = [] + @Published var isLoading = false + @Published var errorMessage: String? + + // MARK: - Chart Data + + @Published var evolutionData: [(date: Date, value: Decimal)] = [] + + // MARK: - Dependencies + + private let categoryRepository: CategoryRepository + private let sourceRepository: InvestmentSourceRepository + private let snapshotRepository: SnapshotRepository + private let calculationService: CalculationService + private var cancellables = Set() + + // MARK: - Initialization + + init( + categoryRepository: CategoryRepository = CategoryRepository(), + sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(), + snapshotRepository: SnapshotRepository = SnapshotRepository(), + calculationService: CalculationService = .shared + ) { + self.categoryRepository = categoryRepository + self.sourceRepository = sourceRepository + self.snapshotRepository = snapshotRepository + self.calculationService = calculationService + + setupObservers() + loadData() + } + + // MARK: - Setup + + private func setupObservers() { + // Observe category changes + categoryRepository.$categories + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshData() + } + .store(in: &cancellables) + + // Observe source changes + sourceRepository.$sources + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshData() + } + .store(in: &cancellables) + + // Observe Core Data changes + NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange) + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshData() + } + .store(in: &cancellables) + } + + // MARK: - Data Loading + + func loadData() { + isLoading = true + errorMessage = nil + + Task { + await refreshAllData() + isLoading = false + } + } + + func refreshData() { + Task { + await refreshAllData() + } + } + + private func refreshAllData() async { + let categories = categoryRepository.categories + let sources = sourceRepository.sources + let allSnapshots = snapshotRepository.fetchAllSnapshots() + + // Calculate portfolio summary + portfolioSummary = calculationService.calculatePortfolioSummary( + from: sources, + snapshots: allSnapshots + ) + + // Calculate category metrics + categoryMetrics = calculationService.calculateCategoryMetrics( + for: categories, + totalPortfolioValue: portfolioSummary.totalValue + ).sorted { $0.totalValue > $1.totalValue } + + // Get recent snapshots + recentSnapshots = Array(allSnapshots.prefix(10)) + + // Get sources needing update + sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate() + + // Calculate evolution data for chart + calculateEvolutionData(from: allSnapshots) + + // Log screen view + FirebaseService.shared.logScreenView(screenName: "Dashboard") + } + + private func calculateEvolutionData(from snapshots: [Snapshot]) { + // Group snapshots by date and sum values + var dateValues: [Date: Decimal] = [:] + + // Get unique dates + let sortedSnapshots = snapshots.sorted { $0.date < $1.date } + + for snapshot in sortedSnapshots { + let startOfDay = Calendar.current.startOfDay(for: snapshot.date) + + // For each date, we need the total portfolio value at that point + // This requires summing the latest value for each source up to that date + let sourcesAtDate = Dictionary(grouping: sortedSnapshots.filter { $0.date <= snapshot.date }) { + $0.source?.id ?? UUID() + } + + var totalAtDate: Decimal = 0 + for (_, sourceSnapshots) in sourcesAtDate { + if let latestForSource = sourceSnapshots.max(by: { $0.date < $1.date }) { + totalAtDate += latestForSource.decimalValue + } + } + + dateValues[startOfDay] = totalAtDate + } + + evolutionData = dateValues + .map { (date: $0.key, value: $0.value) } + .sorted { $0.date < $1.date } + } + + // MARK: - Computed Properties + + var hasData: Bool { + !sourceRepository.sources.isEmpty + } + + var totalSourceCount: Int { + sourceRepository.sourceCount + } + + var totalCategoryCount: Int { + categoryRepository.categories.count + } + + var pendingUpdatesCount: Int { + sourcesNeedingUpdate.count + } + + var topCategories: [CategoryMetrics] { + Array(categoryMetrics.prefix(5)) + } + + // MARK: - Formatting + + var formattedTotalValue: String { + portfolioSummary.formattedTotalValue + } + + var formattedDayChange: String { + portfolioSummary.formattedDayChange + } + + var formattedMonthChange: String { + portfolioSummary.formattedMonthChange + } + + var formattedYearChange: String { + portfolioSummary.formattedYearChange + } + + var isDayChangePositive: Bool { + portfolioSummary.dayChange >= 0 + } + + var isMonthChangePositive: Bool { + portfolioSummary.monthChange >= 0 + } + + var isYearChangePositive: Bool { + portfolioSummary.yearChange >= 0 + } +} diff --git a/InvestmentTracker/ViewModels/SettingsViewModel.swift b/InvestmentTracker/ViewModels/SettingsViewModel.swift new file mode 100644 index 0000000..e89718d --- /dev/null +++ b/InvestmentTracker/ViewModels/SettingsViewModel.swift @@ -0,0 +1,247 @@ +import Foundation +import Combine +import UIKit + +@MainActor +class SettingsViewModel: ObservableObject { + // MARK: - Published Properties + + @Published var isPremium = false + @Published var isFamilyShared = false + @Published var notificationsEnabled = false + @Published var defaultNotificationTime = Date() + @Published var analyticsEnabled = true + + @Published var isLoading = false + @Published var showingPaywall = false + @Published var showingExportOptions = false + @Published var showingResetConfirmation = false + @Published var errorMessage: String? + @Published var successMessage: String? + + // MARK: - Statistics + + @Published var totalSources = 0 + @Published var totalSnapshots = 0 + @Published var totalCategories = 0 + + // MARK: - Dependencies + + private let iapService: IAPService + private let notificationService: NotificationService + private let sourceRepository: InvestmentSourceRepository + private let categoryRepository: CategoryRepository + private let freemiumValidator: FreemiumValidator + private var cancellables = Set() + + // MARK: - Initialization + + init( + iapService: IAPService, + notificationService: NotificationService = .shared, + sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(), + categoryRepository: CategoryRepository = CategoryRepository() + ) { + self.iapService = iapService + self.notificationService = notificationService + self.sourceRepository = sourceRepository + self.categoryRepository = categoryRepository + self.freemiumValidator = FreemiumValidator(iapService: iapService) + + setupObservers() + loadSettings() + } + + // MARK: - Setup + + private func setupObservers() { + iapService.$isPremium + .receive(on: DispatchQueue.main) + .assign(to: &$isPremium) + + iapService.$isFamilyShared + .receive(on: DispatchQueue.main) + .assign(to: &$isFamilyShared) + + notificationService.$isAuthorized + .receive(on: DispatchQueue.main) + .assign(to: &$notificationsEnabled) + } + + // MARK: - Data Loading + + func loadSettings() { + let context = CoreDataStack.shared.viewContext + let settings = AppSettings.getOrCreate(in: context) + + defaultNotificationTime = settings.defaultNotificationTime ?? Date() + analyticsEnabled = settings.enableAnalytics + + // Load statistics + totalSources = sourceRepository.sourceCount + totalCategories = categoryRepository.categories.count + totalSnapshots = sourceRepository.sources.reduce(0) { $0 + $1.snapshotCount } + + FirebaseService.shared.logScreenView(screenName: "Settings") + } + + // MARK: - Premium Actions + + func upgradeToPremium() { + showingPaywall = true + FirebaseService.shared.logPaywallShown(trigger: "settings_upgrade") + } + + func restorePurchases() async { + isLoading = true + await iapService.restorePurchases() + isLoading = false + + if isPremium { + successMessage = "Purchases restored successfully!" + FirebaseService.shared.logRestorePurchases(success: true) + } else { + errorMessage = "No purchases to restore" + FirebaseService.shared.logRestorePurchases(success: false) + } + } + + // MARK: - Notification Settings + + func requestNotificationPermission() async { + let granted = await notificationService.requestAuthorization() + if !granted { + errorMessage = "Please enable notifications in Settings" + } + } + + func updateNotificationTime(_ time: Date) { + defaultNotificationTime = time + + let context = CoreDataStack.shared.viewContext + let settings = AppSettings.getOrCreate(in: context) + settings.defaultNotificationTime = time + CoreDataStack.shared.save() + + // Reschedule all notifications with new time + let sources = sourceRepository.fetchActiveSources() + notificationService.scheduleAllReminders(for: sources) + } + + func openSystemSettings() { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + + // MARK: - Export + + func exportData(format: ExportService.ExportFormat) { + guard freemiumValidator.canExport() else { + showingPaywall = true + FirebaseService.shared.logPaywallShown(trigger: "export") + return + } + + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let viewController = windowScene.windows.first?.rootViewController else { + return + } + + ExportService.shared.share( + format: format, + sources: sourceRepository.sources, + categories: categoryRepository.categories, + from: viewController + ) + } + + var canExport: Bool { + freemiumValidator.canExport() + } + + // MARK: - Analytics + + func toggleAnalytics(_ enabled: Bool) { + analyticsEnabled = enabled + + let context = CoreDataStack.shared.viewContext + let settings = AppSettings.getOrCreate(in: context) + settings.enableAnalytics = enabled + CoreDataStack.shared.save() + + // Note: In production, you'd also update Firebase Analytics consent + } + + // MARK: - Data Management + + func resetAllData() { + let context = CoreDataStack.shared.viewContext + + // Delete all snapshots, sources, and categories + for source in sourceRepository.sources { + context.delete(source) + } + for category in categoryRepository.categories { + context.delete(category) + } + + CoreDataStack.shared.save() + + // Clear notifications + notificationService.cancelAllReminders() + + // Recreate default categories + categoryRepository.createDefaultCategoriesIfNeeded() + + // Reload data + loadSettings() + + successMessage = "All data has been reset" + } + + // MARK: - Computed Properties + + var appVersion: String { + "\(AppConstants.appVersion) (\(AppConstants.buildNumber))" + } + + var premiumStatusText: String { + if isPremium { + return isFamilyShared ? "Premium (Family)" : "Premium" + } + return "Free" + } + + var sourceLimitText: String { + if isPremium { + return "Unlimited" + } + return "\(totalSources)/\(FreemiumLimits.maxSources)" + } + + var historyLimitText: String { + if isPremium { + return "Full history" + } + return "Last \(FreemiumLimits.maxHistoricalMonths) months" + } + + var storageUsedText: String { + let bytes = calculateStorageUsed() + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(bytes)) + } + + private func calculateStorageUsed() -> Int { + guard let storeURL = CoreDataStack.sharedStoreURL else { return 0 } + + do { + let attributes = try FileManager.default.attributesOfItem(atPath: storeURL.path) + return attributes[.size] as? Int ?? 0 + } catch { + return 0 + } + } +} diff --git a/InvestmentTracker/ViewModels/SnapshotFormViewModel.swift b/InvestmentTracker/ViewModels/SnapshotFormViewModel.swift new file mode 100644 index 0000000..31ff91a --- /dev/null +++ b/InvestmentTracker/ViewModels/SnapshotFormViewModel.swift @@ -0,0 +1,193 @@ +import Foundation +import Combine + +@MainActor +class SnapshotFormViewModel: ObservableObject { + // MARK: - Published Properties + + @Published var date = Date() + @Published var valueString = "" + @Published var contributionString = "" + @Published var notes = "" + @Published var includeContribution = false + + @Published var isValid = false + @Published var errorMessage: String? + + // MARK: - Mode + + enum Mode { + case add + case edit(Snapshot) + } + + let mode: Mode + let source: InvestmentSource + + // MARK: - Dependencies + + private var cancellables = Set() + + // MARK: - Initialization + + init(source: InvestmentSource, mode: Mode = .add) { + self.source = source + self.mode = mode + + setupValidation() + + // Pre-fill for edit mode + if case .edit(let snapshot) = mode { + date = snapshot.date + valueString = formatDecimalForInput(snapshot.decimalValue) + if let contribution = snapshot.contribution { + includeContribution = true + contributionString = formatDecimalForInput(contribution.decimalValue) + } + notes = snapshot.notes ?? "" + } + } + + // MARK: - Validation + + private func setupValidation() { + Publishers.CombineLatest3($valueString, $contributionString, $includeContribution) + .map { [weak self] valueStr, contribStr, includeContrib in + self?.validateInputs( + valueString: valueStr, + contributionString: contribStr, + includeContribution: includeContrib + ) ?? false + } + .assign(to: &$isValid) + } + + private func validateInputs( + valueString: String, + contributionString: String, + includeContribution: Bool + ) -> Bool { + // Value is required + guard let value = parseDecimal(valueString), value >= 0 else { + return false + } + + // Contribution is optional but must be valid if included + if includeContribution { + guard let contribution = parseDecimal(contributionString), contribution >= 0 else { + return false + } + } + + return true + } + + // MARK: - Parsing + + private func parseDecimal(_ string: String) -> Decimal? { + let cleaned = string + .replacingOccurrences(of: "€", with: "") + .replacingOccurrences(of: ",", with: ".") + .trimmingCharacters(in: .whitespaces) + + guard !cleaned.isEmpty else { return nil } + + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = Locale(identifier: "en_US") + + return formatter.number(from: cleaned)?.decimalValue + } + + private func formatDecimalForInput(_ decimal: Decimal) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = 2 + formatter.maximumFractionDigits = 2 + formatter.groupingSeparator = "" + return formatter.string(from: decimal as NSDecimalNumber) ?? "" + } + + // MARK: - Computed Properties + + var value: Decimal? { + parseDecimal(valueString) + } + + var contribution: Decimal? { + guard includeContribution else { return nil } + return parseDecimal(contributionString) + } + + var formattedValue: String { + guard let value = value else { return "€0.00" } + return value.currencyString + } + + var title: String { + switch mode { + case .add: + return "Add Snapshot" + case .edit: + return "Edit Snapshot" + } + } + + var buttonTitle: String { + switch mode { + case .add: + return "Add Snapshot" + case .edit: + return "Save Changes" + } + } + + // MARK: - Previous Value Reference + + var previousValue: Decimal? { + source.latestSnapshot?.decimalValue + } + + var previousValueString: String { + guard let previous = previousValue else { return "No previous value" } + return "Previous: \(previous.currencyString)" + } + + var changeFromPrevious: Decimal? { + guard let current = value, let previous = previousValue else { return nil } + return current - previous + } + + var changePercentageFromPrevious: Double? { + guard let current = value, + let previous = previousValue, + previous != 0 else { return nil } + + return NSDecimalNumber(decimal: (current - previous) / previous).doubleValue * 100 + } + + var formattedChange: String? { + guard let change = changeFromPrevious else { return nil } + let prefix = change >= 0 ? "+" : "" + return "\(prefix)\(change.currencyString)" + } + + var formattedChangePercentage: String? { + guard let percentage = changePercentageFromPrevious else { return nil } + let prefix = percentage >= 0 ? "+" : "" + return String(format: "\(prefix)%.2f%%", percentage) + } + + // MARK: - Date Validation + + var isDateInFuture: Bool { + date > Date() + } + + var dateWarning: String? { + if isDateInFuture { + return "Date is in the future" + } + return nil + } +} diff --git a/InvestmentTracker/ViewModels/SourceDetailViewModel.swift b/InvestmentTracker/ViewModels/SourceDetailViewModel.swift new file mode 100644 index 0000000..22eb23b --- /dev/null +++ b/InvestmentTracker/ViewModels/SourceDetailViewModel.swift @@ -0,0 +1,229 @@ +import Foundation +import Combine +import CoreData + +@MainActor +class SourceDetailViewModel: ObservableObject { + // MARK: - Published Properties + + @Published var source: InvestmentSource + @Published var snapshots: [Snapshot] = [] + @Published var metrics: InvestmentMetrics = .empty + @Published var predictions: [Prediction] = [] + @Published var predictionResult: PredictionResult? + + @Published var isLoading = false + @Published var showingAddSnapshot = false + @Published var showingEditSource = false + @Published var showingPaywall = false + @Published var errorMessage: String? + + // MARK: - Chart Data + + @Published var chartData: [(date: Date, value: Decimal)] = [] + + // MARK: - Dependencies + + private let snapshotRepository: SnapshotRepository + private let sourceRepository: InvestmentSourceRepository + private let calculationService: CalculationService + private let predictionEngine: PredictionEngine + private let freemiumValidator: FreemiumValidator + private var cancellables = Set() + + // MARK: - Initialization + + init( + source: InvestmentSource, + snapshotRepository: SnapshotRepository = SnapshotRepository(), + sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(), + calculationService: CalculationService = .shared, + predictionEngine: PredictionEngine = .shared, + iapService: IAPService + ) { + self.source = source + self.snapshotRepository = snapshotRepository + self.sourceRepository = sourceRepository + self.calculationService = calculationService + self.predictionEngine = predictionEngine + self.freemiumValidator = FreemiumValidator(iapService: iapService) + + loadData() + setupObservers() + } + + // MARK: - Setup + + private func setupObservers() { + NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange) + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshData() + } + .store(in: &cancellables) + } + + // MARK: - Data Loading + + func loadData() { + isLoading = true + refreshData() + isLoading = false + + FirebaseService.shared.logScreenView(screenName: "SourceDetail") + } + + func refreshData() { + // Fetch snapshots (filtered by freemium limits) + let allSnapshots = snapshotRepository.fetchSnapshots(for: source) + snapshots = freemiumValidator.filterSnapshots(allSnapshots) + + // Calculate metrics + metrics = calculationService.calculateMetrics(for: snapshots) + + // Prepare chart data + chartData = snapshots + .sorted { $0.date < $1.date } + .map { (date: $0.date, value: $0.decimalValue) } + + // Calculate predictions if premium + if freemiumValidator.canViewPredictions() && snapshots.count >= 3 { + predictionResult = predictionEngine.predict(snapshots: snapshots) + predictions = predictionResult?.predictions ?? [] + } else { + predictions = [] + predictionResult = nil + } + } + + // MARK: - Snapshot Actions + + func addSnapshot(date: Date, value: Decimal, contribution: Decimal?, notes: String?) { + snapshotRepository.createSnapshot( + for: source, + date: date, + value: value, + contribution: contribution, + notes: notes + ) + + // Reschedule notification + NotificationService.shared.scheduleReminder(for: source) + + // Log analytics + FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value) + + showingAddSnapshot = false + refreshData() + } + + func deleteSnapshot(_ snapshot: Snapshot) { + snapshotRepository.deleteSnapshot(snapshot) + refreshData() + } + + func deleteSnapshot(at offsets: IndexSet) { + snapshotRepository.deleteSnapshot(at: offsets, from: snapshots) + refreshData() + } + + // MARK: - Source Actions + + func updateSource( + name: String, + category: Category, + frequency: NotificationFrequency, + customMonths: Int + ) { + sourceRepository.updateSource( + source, + name: name, + category: category, + notificationFrequency: frequency, + customFrequencyMonths: customMonths + ) + + // Update notification + NotificationService.shared.scheduleReminder(for: source) + + showingEditSource = false + } + + // MARK: - Predictions + + func showPredictions() { + if freemiumValidator.canViewPredictions() { + // Already loaded, just navigate + FirebaseService.shared.logPredictionViewed( + algorithm: predictionResult?.algorithm.rawValue ?? "unknown" + ) + } else { + showingPaywall = true + FirebaseService.shared.logPaywallShown(trigger: "predictions") + } + } + + // MARK: - Computed Properties + + var currentValue: Decimal { + source.latestValue + } + + var formattedCurrentValue: String { + currentValue.currencyString + } + + var totalReturn: Decimal { + metrics.absoluteReturn + } + + var formattedTotalReturn: String { + metrics.formattedAbsoluteReturn + } + + var percentageReturn: Decimal { + metrics.percentageReturn + } + + var formattedPercentageReturn: String { + metrics.formattedPercentageReturn + } + + var isPositiveReturn: Bool { + totalReturn >= 0 + } + + var categoryName: String { + source.category?.name ?? "Uncategorized" + } + + var categoryColor: String { + source.category?.colorHex ?? "#3B82F6" + } + + var lastUpdated: String { + source.latestSnapshot?.date.friendlyDescription ?? "Never" + } + + var snapshotCount: Int { + snapshots.count + } + + var hasEnoughDataForPredictions: Bool { + snapshots.count >= 3 + } + + var canViewPredictions: Bool { + freemiumValidator.canViewPredictions() + } + + var isHistoryLimited: Bool { + !freemiumValidator.isPremium && + snapshotRepository.fetchSnapshots(for: source).count > snapshots.count + } + + var hiddenSnapshotCount: Int { + let allCount = snapshotRepository.fetchSnapshots(for: source).count + return allCount - snapshots.count + } +} diff --git a/InvestmentTracker/ViewModels/SourceListViewModel.swift b/InvestmentTracker/ViewModels/SourceListViewModel.swift new file mode 100644 index 0000000..070e798 --- /dev/null +++ b/InvestmentTracker/ViewModels/SourceListViewModel.swift @@ -0,0 +1,213 @@ +import Foundation +import Combine +import CoreData + +@MainActor +class SourceListViewModel: ObservableObject { + // MARK: - Published Properties + + @Published var sources: [InvestmentSource] = [] + @Published var categories: [Category] = [] + @Published var selectedCategory: Category? + @Published var searchText = "" + @Published var isLoading = false + @Published var showingAddSource = false + @Published var showingPaywall = false + @Published var errorMessage: String? + + // MARK: - Dependencies + + private let sourceRepository: InvestmentSourceRepository + private let categoryRepository: CategoryRepository + private let freemiumValidator: FreemiumValidator + private var cancellables = Set() + + // MARK: - Initialization + + init( + sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(), + categoryRepository: CategoryRepository = CategoryRepository(), + iapService: IAPService + ) { + self.sourceRepository = sourceRepository + self.categoryRepository = categoryRepository + self.freemiumValidator = FreemiumValidator(iapService: iapService) + + setupObservers() + loadData() + } + + // MARK: - Setup + + private func setupObservers() { + sourceRepository.$sources + .receive(on: DispatchQueue.main) + .sink { [weak self] sources in + self?.filterAndSortSources(sources) + } + .store(in: &cancellables) + + categoryRepository.$categories + .receive(on: DispatchQueue.main) + .sink { [weak self] categories in + self?.categories = categories + } + .store(in: &cancellables) + + $searchText + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + self?.filterAndSortSources(self?.sourceRepository.sources ?? []) + } + .store(in: &cancellables) + + $selectedCategory + .sink { [weak self] _ in + self?.filterAndSortSources(self?.sourceRepository.sources ?? []) + } + .store(in: &cancellables) + } + + // MARK: - Data Loading + + func loadData() { + isLoading = true + categoryRepository.createDefaultCategoriesIfNeeded() + sourceRepository.fetchSources() + categoryRepository.fetchCategories() + isLoading = false + + FirebaseService.shared.logScreenView(screenName: "SourceList") + } + + private func filterAndSortSources(_ allSources: [InvestmentSource]) { + var filtered = allSources + + // Filter by category + if let category = selectedCategory { + filtered = filtered.filter { $0.category?.id == category.id } + } + + // Filter by search text + if !searchText.isEmpty { + filtered = filtered.filter { + $0.name.localizedCaseInsensitiveContains(searchText) || + ($0.category?.name.localizedCaseInsensitiveContains(searchText) ?? false) + } + } + + // Sort by value descending + sources = filtered.sorted { $0.latestValue > $1.latestValue } + } + + // MARK: - Actions + + func addSourceTapped() { + if freemiumValidator.canAddSource(currentCount: sourceRepository.sourceCount) { + showingAddSource = true + } else { + showingPaywall = true + FirebaseService.shared.logPaywallShown(trigger: "source_limit") + } + } + + func createSource( + name: String, + category: Category, + frequency: NotificationFrequency, + customMonths: Int = 1 + ) { + let source = sourceRepository.createSource( + name: name, + category: category, + notificationFrequency: frequency, + customFrequencyMonths: customMonths + ) + + // Schedule notification + NotificationService.shared.scheduleReminder(for: source) + + // Log analytics + FirebaseService.shared.logSourceAdded( + categoryName: category.name, + sourceCount: sourceRepository.sourceCount + ) + + showingAddSource = false + } + + func deleteSource(_ source: InvestmentSource) { + let categoryName = source.category?.name ?? "Unknown" + + // Cancel notifications + NotificationService.shared.cancelReminder(for: source) + + // Delete source + sourceRepository.deleteSource(source) + + // Log analytics + FirebaseService.shared.logSourceDeleted(categoryName: categoryName) + } + + func deleteSource(at offsets: IndexSet) { + for index in offsets { + guard index < sources.count else { continue } + deleteSource(sources[index]) + } + } + + func toggleSourceActive(_ source: InvestmentSource) { + sourceRepository.toggleActive(source) + + if source.isActive { + NotificationService.shared.scheduleReminder(for: source) + } else { + NotificationService.shared.cancelReminder(for: source) + } + } + + // MARK: - Computed Properties + + var canAddSource: Bool { + freemiumValidator.canAddSource(currentCount: sourceRepository.sourceCount) + } + + var remainingSources: Int { + freemiumValidator.remainingSources(currentCount: sourceRepository.sourceCount) + } + + var sourceLimitReached: Bool { + !canAddSource + } + + var totalValue: Decimal { + sources.reduce(Decimal.zero) { $0 + $1.latestValue } + } + + var formattedTotalValue: String { + totalValue.currencyString + } + + var sourcesByCategory: [Category: [InvestmentSource]] { + Dictionary(grouping: sources) { $0.category ?? categories.first! } + } + + var isEmpty: Bool { + sources.isEmpty && searchText.isEmpty && selectedCategory == nil + } + + var isFiltered: Bool { + !searchText.isEmpty || selectedCategory != nil + } + + // MARK: - Category Filter + + func selectCategory(_ category: Category?) { + selectedCategory = category + } + + func clearFilters() { + searchText = "" + selectedCategory = nil + } +} diff --git a/InvestmentTracker/Views/Charts/AllocationPieChart.swift b/InvestmentTracker/Views/Charts/AllocationPieChart.swift new file mode 100644 index 0000000..d8257b0 --- /dev/null +++ b/InvestmentTracker/Views/Charts/AllocationPieChart.swift @@ -0,0 +1,197 @@ +import SwiftUI +import Charts + +struct AllocationPieChart: View { + let data: [(category: String, value: Decimal, color: String)] + + @State private var selectedSlice: String? + + var total: Decimal { + data.reduce(Decimal.zero) { $0 + $1.value } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Asset Allocation") + .font(.headline) + + if !data.isEmpty { + HStack(alignment: .top, spacing: 20) { + // Pie Chart + Chart(data, id: \.category) { item in + SectorMark( + angle: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue), + innerRadius: .ratio(0.6), + angularInset: 1.5 + ) + .foregroundStyle(Color(hex: item.color) ?? .gray) + .cornerRadius(4) + .opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5) + } + .chartLegend(.hidden) + .frame(width: 180, height: 180) + .overlay { + // Center content + VStack(spacing: 2) { + if let selected = selectedSlice, + let item = data.first(where: { $0.category == selected }) { + Text(selected) + .font(.caption) + .foregroundColor(.secondary) + Text(item.value.compactCurrencyString) + .font(.headline) + let percentage = total > 0 + ? NSDecimalNumber(decimal: item.value / total).doubleValue * 100 + : 0 + Text(String(format: "%.1f%%", percentage)) + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("Total") + .font(.caption) + .foregroundColor(.secondary) + Text(total.compactCurrencyString) + .font(.headline) + } + } + } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + // Simple tap detection + } + ) + + // Legend + VStack(alignment: .leading, spacing: 8) { + ForEach(data, id: \.category) { item in + Button { + if selectedSlice == item.category { + selectedSlice = nil + } else { + selectedSlice = item.category + } + } label: { + HStack(spacing: 8) { + Circle() + .fill(Color(hex: item.color) ?? .gray) + .frame(width: 10, height: 10) + + VStack(alignment: .leading, spacing: 0) { + Text(item.category) + .font(.caption) + .foregroundColor(.primary) + + let percentage = total > 0 + ? NSDecimalNumber(decimal: item.value / total).doubleValue * 100 + : 0 + Text(String(format: "%.1f%%", percentage)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + .opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5) + } + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + Text("No allocation data available") + .foregroundColor(.secondary) + .frame(height: 200) + .frame(maxWidth: .infinity) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +// MARK: - Allocation List View (Alternative) + +struct AllocationListView: View { + let data: [(category: String, value: Decimal, color: String)] + + var total: Decimal { + data.reduce(Decimal.zero) { $0 + $1.value } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Asset Allocation") + .font(.headline) + + ForEach(data, id: \.category) { item in + VStack(spacing: 4) { + HStack { + HStack(spacing: 8) { + Circle() + .fill(Color(hex: item.color) ?? .gray) + .frame(width: 10, height: 10) + + Text(item.category) + .font(.subheadline) + } + + Spacer() + + let percentage = total > 0 + ? NSDecimalNumber(decimal: item.value / total).doubleValue * 100 + : 0 + + Text(String(format: "%.1f%%", percentage)) + .font(.subheadline.weight(.medium)) + + Text(item.value.compactCurrencyString) + .font(.caption) + .foregroundColor(.secondary) + .frame(width: 70, alignment: .trailing) + } + + // Progress bar + GeometryReader { geometry in + let percentage = total > 0 + ? NSDecimalNumber(decimal: item.value / total).doubleValue + : 0 + + ZStack(alignment: .leading) { + Rectangle() + .fill(Color.gray.opacity(0.1)) + .frame(height: 6) + .cornerRadius(3) + + Rectangle() + .fill(Color(hex: item.color) ?? .gray) + .frame(width: geometry.size.width * percentage, height: 6) + .cornerRadius(3) + } + } + .frame(height: 6) + } + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +#Preview { + let sampleData: [(category: String, value: Decimal, color: String)] = [ + ("Stocks", 50000, "#10B981"), + ("Bonds", 25000, "#3B82F6"), + ("Real Estate", 15000, "#F59E0B"), + ("Crypto", 10000, "#8B5CF6") + ] + + return VStack(spacing: 20) { + AllocationPieChart(data: sampleData) + AllocationListView(data: sampleData) + } + .padding() +} diff --git a/InvestmentTracker/Views/Charts/ChartsContainerView.swift b/InvestmentTracker/Views/Charts/ChartsContainerView.swift new file mode 100644 index 0000000..6655792 --- /dev/null +++ b/InvestmentTracker/Views/Charts/ChartsContainerView.swift @@ -0,0 +1,298 @@ +import SwiftUI +import Charts + +struct ChartsContainerView: View { + @EnvironmentObject var iapService: IAPService + @StateObject private var viewModel: ChartsViewModel + + init() { + _viewModel = StateObject(wrappedValue: ChartsViewModel(iapService: IAPService())) + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 20) { + // Chart Type Selector + chartTypeSelector + + // Time Range Selector + if viewModel.selectedChartType != .allocation && + viewModel.selectedChartType != .performance { + timeRangeSelector + } + + // Category Filter + if viewModel.selectedChartType == .evolution || + viewModel.selectedChartType == .prediction { + categoryFilter + } + + // Chart Content + chartContent + } + .padding() + } + .navigationTitle("Charts") + .sheet(isPresented: $viewModel.showingPaywall) { + PaywallView() + } + } + } + + // MARK: - Chart Type Selector + + private var chartTypeSelector: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(ChartsViewModel.ChartType.allCases) { chartType in + ChartTypeButton( + chartType: chartType, + isSelected: viewModel.selectedChartType == chartType, + isPremium: chartType.isPremium, + userIsPremium: viewModel.isPremium + ) { + viewModel.selectChart(chartType) + } + } + } + .padding(.horizontal, 4) + } + } + + // MARK: - Time Range Selector + + private var timeRangeSelector: some View { + HStack(spacing: 8) { + ForEach(ChartsViewModel.TimeRange.allCases) { range in + Button { + viewModel.selectedTimeRange = range + } label: { + Text(range.rawValue) + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + viewModel.selectedTimeRange == range + ? Color.appPrimary + : Color.gray.opacity(0.1) + ) + .foregroundColor( + viewModel.selectedTimeRange == range + ? .white + : .primary + ) + .cornerRadius(20) + } + } + } + } + + // MARK: - Category Filter + + private var categoryFilter: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + Button { + viewModel.selectedCategory = nil + } label: { + Text("All") + .font(.caption.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + viewModel.selectedCategory == nil + ? Color.appPrimary + : Color.gray.opacity(0.1) + ) + .foregroundColor( + viewModel.selectedCategory == nil + ? .white + : .primary + ) + .cornerRadius(16) + } + + ForEach(viewModel.categories) { category in + Button { + viewModel.selectedCategory = category + } label: { + HStack(spacing: 4) { + Circle() + .fill(category.color) + .frame(width: 8, height: 8) + Text(category.name) + } + .font(.caption.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + viewModel.selectedCategory?.id == category.id + ? category.color + : Color.gray.opacity(0.1) + ) + .foregroundColor( + viewModel.selectedCategory?.id == category.id + ? .white + : .primary + ) + .cornerRadius(16) + } + } + } + } + } + + // MARK: - Chart Content + + @ViewBuilder + private var chartContent: some View { + if viewModel.isLoading { + ProgressView() + .frame(height: 300) + } else if !viewModel.hasData { + emptyStateView + } else { + switch viewModel.selectedChartType { + case .evolution: + EvolutionChartView(data: viewModel.evolutionData) + case .allocation: + AllocationPieChart(data: viewModel.allocationData) + case .performance: + PerformanceBarChart(data: viewModel.performanceData) + case .drawdown: + DrawdownChart(data: viewModel.drawdownData) + case .volatility: + VolatilityChartView(data: viewModel.volatilityData) + case .prediction: + PredictionChartView( + predictions: viewModel.predictionData, + historicalData: viewModel.evolutionData + ) + } + } + } + + private var emptyStateView: some View { + VStack(spacing: 16) { + Image(systemName: "chart.bar.xaxis") + .font(.system(size: 48)) + .foregroundColor(.secondary) + + Text("No Data Available") + .font(.headline) + + Text("Add some investment sources and snapshots to see charts.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(height: 300) + } +} + +// MARK: - Chart Type Button + +struct ChartTypeButton: View { + let chartType: ChartsViewModel.ChartType + let isSelected: Bool + let isPremium: Bool + let userIsPremium: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + ZStack(alignment: .topTrailing) { + Image(systemName: chartType.icon) + .font(.title2) + + if isPremium && !userIsPremium { + Image(systemName: "lock.fill") + .font(.caption2) + .foregroundColor(.appWarning) + .offset(x: 8, y: -4) + } + } + + Text(chartType.rawValue) + .font(.caption) + } + .frame(width: 80, height: 70) + .background( + isSelected + ? Color.appPrimary + : Color(.systemBackground) + ) + .foregroundColor(isSelected ? .white : .primary) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 4, y: 2) + } + } +} + +// MARK: - Evolution Chart View + +struct EvolutionChartView: View { + let data: [(date: Date, value: Decimal)] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Portfolio Evolution") + .font(.headline) + + if data.count >= 2 { + Chart(data, id: \.date) { item in + LineMark( + x: .value("Date", item.date), + y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) + ) + .foregroundStyle(Color.appPrimary) + .interpolationMethod(.catmullRom) + + AreaMark( + x: .value("Date", item.date), + y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) + ) + .foregroundStyle( + LinearGradient( + colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)], + startPoint: .top, + endPoint: .bottom + ) + ) + .interpolationMethod(.catmullRom) + } + .chartXAxis { + AxisMarks(values: .stride(by: .month, count: 2)) { value in + AxisValueLabel(format: .dateTime.month(.abbreviated)) + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisValueLabel { + if let doubleValue = value.as(Double.self) { + Text(Decimal(doubleValue).shortCurrencyString) + .font(.caption) + } + } + } + } + .frame(height: 300) + } else { + Text("Not enough data") + .foregroundColor(.secondary) + .frame(height: 300) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +#Preview { + ChartsContainerView() + .environmentObject(IAPService()) +} diff --git a/InvestmentTracker/Views/Charts/DrawdownChart.swift b/InvestmentTracker/Views/Charts/DrawdownChart.swift new file mode 100644 index 0000000..9160d65 --- /dev/null +++ b/InvestmentTracker/Views/Charts/DrawdownChart.swift @@ -0,0 +1,304 @@ +import SwiftUI +import Charts + +struct DrawdownChart: View { + let data: [(date: Date, drawdown: Double)] + + var maxDrawdown: Double { + abs(data.map { $0.drawdown }.min() ?? 0) + } + + var currentDrawdown: Double { + abs(data.last?.drawdown ?? 0) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Drawdown Analysis") + .font(.headline) + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text("Max Drawdown") + .font(.caption) + .foregroundColor(.secondary) + Text(String(format: "%.1f%%", maxDrawdown)) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.negativeRed) + } + } + + Text("Shows percentage decline from peak values") + .font(.caption) + .foregroundColor(.secondary) + + if data.count >= 2 { + Chart(data, id: \.date) { item in + AreaMark( + x: .value("Date", item.date), + y: .value("Drawdown", item.drawdown) + ) + .foregroundStyle( + LinearGradient( + colors: [Color.negativeRed.opacity(0.5), Color.negativeRed.opacity(0.1)], + startPoint: .top, + endPoint: .bottom + ) + ) + .interpolationMethod(.catmullRom) + + LineMark( + x: .value("Date", item.date), + y: .value("Drawdown", item.drawdown) + ) + .foregroundStyle(Color.negativeRed) + .interpolationMethod(.catmullRom) + } + .chartXAxis { + AxisMarks(values: .stride(by: .month, count: 2)) { value in + AxisValueLabel(format: .dateTime.month(.abbreviated)) + } + } + .chartYAxis { + AxisMarks { value in + AxisGridLine() + AxisValueLabel { + if let doubleValue = value.as(Double.self) { + Text(String(format: "%.0f%%", doubleValue)) + .font(.caption) + } + } + } + } + .chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0) + .frame(height: 250) + + // Statistics + HStack(spacing: 20) { + DrawdownStatView( + title: "Current", + value: String(format: "%.1f%%", currentDrawdown), + isHighlighted: currentDrawdown > maxDrawdown * 0.8 + ) + + DrawdownStatView( + title: "Maximum", + value: String(format: "%.1f%%", maxDrawdown), + isHighlighted: true + ) + + DrawdownStatView( + title: "Average", + value: String(format: "%.1f%%", averageDrawdown), + isHighlighted: false + ) + } + .padding(.top, 8) + } else { + Text("Not enough data for drawdown analysis") + .foregroundColor(.secondary) + .frame(height: 250) + .frame(maxWidth: .infinity) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } + + private var averageDrawdown: Double { + guard !data.isEmpty else { return 0 } + let sum = data.reduce(0.0) { $0 + abs($1.drawdown) } + return sum / Double(data.count) + } +} + +struct DrawdownStatView: View { + let title: String + let value: String + let isHighlighted: Bool + + var body: some View { + VStack(spacing: 4) { + Text(title) + .font(.caption) + .foregroundColor(.secondary) + + Text(value) + .font(.subheadline.weight(.semibold)) + .foregroundColor(isHighlighted ? .negativeRed : .primary) + } + .frame(maxWidth: .infinity) + } +} + +// MARK: - Volatility Chart View + +struct VolatilityChartView: View { + let data: [(date: Date, volatility: Double)] + + var currentVolatility: Double { + data.last?.volatility ?? 0 + } + + var averageVolatility: Double { + guard !data.isEmpty else { return 0 } + return data.reduce(0.0) { $0 + $1.volatility } / Double(data.count) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Volatility") + .font(.headline) + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text("Current") + .font(.caption) + .foregroundColor(.secondary) + Text(String(format: "%.1f%%", currentVolatility)) + .font(.subheadline.weight(.semibold)) + .foregroundColor(volatilityColor(currentVolatility)) + } + } + + Text("Measures price variability over time") + .font(.caption) + .foregroundColor(.secondary) + + if data.count >= 2 { + Chart(data, id: \.date) { item in + LineMark( + x: .value("Date", item.date), + y: .value("Volatility", item.volatility) + ) + .foregroundStyle(Color.appPrimary) + .interpolationMethod(.catmullRom) + + AreaMark( + x: .value("Date", item.date), + y: .value("Volatility", item.volatility) + ) + .foregroundStyle( + LinearGradient( + colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)], + startPoint: .top, + endPoint: .bottom + ) + ) + .interpolationMethod(.catmullRom) + + // Average line + RuleMark(y: .value("Average", averageVolatility)) + .foregroundStyle(Color.gray.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5])) + .annotation(position: .top, alignment: .leading) { + Text("Avg: \(String(format: "%.1f%%", averageVolatility))") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .chartXAxis { + AxisMarks(values: .stride(by: .month, count: 2)) { value in + AxisValueLabel(format: .dateTime.month(.abbreviated)) + } + } + .chartYAxis { + AxisMarks { value in + AxisGridLine() + AxisValueLabel { + if let doubleValue = value.as(Double.self) { + Text(String(format: "%.0f%%", doubleValue)) + .font(.caption) + } + } + } + } + .frame(height: 250) + + // Volatility interpretation + HStack(spacing: 16) { + VolatilityLevelView(level: "Low", range: "0-10%", color: .positiveGreen) + VolatilityLevelView(level: "Medium", range: "10-20%", color: .appWarning) + VolatilityLevelView(level: "High", range: "20%+", color: .negativeRed) + } + .padding(.top, 8) + } else { + Text("Not enough data for volatility analysis") + .foregroundColor(.secondary) + .frame(height: 250) + .frame(maxWidth: .infinity) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } + + private func volatilityColor(_ volatility: Double) -> Color { + switch volatility { + case 0..<10: + return .positiveGreen + case 10..<20: + return .appWarning + default: + return .negativeRed + } + } +} + +struct VolatilityLevelView: View { + let level: String + let range: String + let color: Color + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + + VStack(alignment: .leading, spacing: 0) { + Text(level) + .font(.caption2) + Text(range) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } +} + +#Preview { + let drawdownData: [(date: Date, drawdown: Double)] = [ + (Date().adding(months: -6), -5), + (Date().adding(months: -5), -8), + (Date().adding(months: -4), -3), + (Date().adding(months: -3), -15), + (Date().adding(months: -2), -10), + (Date().adding(months: -1), -7), + (Date(), -4) + ] + + let volatilityData: [(date: Date, volatility: Double)] = [ + (Date().adding(months: -6), 12), + (Date().adding(months: -5), 15), + (Date().adding(months: -4), 10), + (Date().adding(months: -3), 22), + (Date().adding(months: -2), 18), + (Date().adding(months: -1), 14), + (Date(), 11) + ] + + return VStack(spacing: 20) { + DrawdownChart(data: drawdownData) + VolatilityChartView(data: volatilityData) + } + .padding() +} diff --git a/InvestmentTracker/Views/Charts/PerformanceBarChart.swift b/InvestmentTracker/Views/Charts/PerformanceBarChart.swift new file mode 100644 index 0000000..f5eced4 --- /dev/null +++ b/InvestmentTracker/Views/Charts/PerformanceBarChart.swift @@ -0,0 +1,159 @@ +import SwiftUI +import Charts + +struct PerformanceBarChart: View { + let data: [(category: String, cagr: Double, color: String)] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Performance by Category") + .font(.headline) + + Text("Compound Annual Growth Rate (CAGR)") + .font(.caption) + .foregroundColor(.secondary) + + if !data.isEmpty { + Chart(data, id: \.category) { item in + BarMark( + x: .value("Category", item.category), + y: .value("CAGR", item.cagr) + ) + .foregroundStyle(Color(hex: item.color) ?? .gray) + .cornerRadius(4) + .annotation(position: item.cagr >= 0 ? .top : .bottom) { + Text(String(format: "%.1f%%", item.cagr)) + .font(.caption2) + .foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed) + } + } + .chartXAxis { + AxisMarks { value in + AxisValueLabel { + if let category = value.as(String.self) { + Text(category) + .font(.caption) + .lineLimit(1) + } + } + } + } + .chartYAxis { + AxisMarks { value in + AxisGridLine() + AxisValueLabel { + if let doubleValue = value.as(Double.self) { + Text(String(format: "%.0f%%", doubleValue)) + .font(.caption) + } + } + } + } + .frame(height: 250) + + // Legend / Details + VStack(spacing: 8) { + ForEach(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in + HStack { + Circle() + .fill(Color(hex: item.color) ?? .gray) + .frame(width: 10, height: 10) + + Text(item.category) + .font(.subheadline) + + Spacer() + + Text(String(format: "%.2f%%", item.cagr)) + .font(.subheadline.weight(.semibold)) + .foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed) + } + } + } + .padding(.top, 8) + } else { + Text("No performance data available") + .foregroundColor(.secondary) + .frame(height: 250) + .frame(maxWidth: .infinity) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +// MARK: - Horizontal Bar Version + +struct HorizontalPerformanceChart: View { + let data: [(category: String, cagr: Double, color: String)] + + var sortedData: [(category: String, cagr: Double, color: String)] { + data.sorted { $0.cagr > $1.cagr } + } + + var maxValue: Double { + max(abs(data.map { $0.cagr }.max() ?? 0), abs(data.map { $0.cagr }.min() ?? 0)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Performance by Category") + .font(.headline) + + ForEach(sortedData, id: \.category) { item in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(item.category) + .font(.subheadline) + + Spacer() + + Text(String(format: "%.2f%%", item.cagr)) + .font(.subheadline.weight(.semibold)) + .foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed) + } + + GeometryReader { geometry in + let normalizedValue = maxValue > 0 ? abs(item.cagr) / maxValue : 0 + let barWidth = geometry.size.width * normalizedValue + + ZStack(alignment: item.cagr >= 0 ? .leading : .trailing) { + Rectangle() + .fill(Color.gray.opacity(0.1)) + .frame(height: 8) + .cornerRadius(4) + + Rectangle() + .fill(item.cagr >= 0 ? Color.positiveGreen : Color.negativeRed) + .frame(width: barWidth, height: 8) + .cornerRadius(4) + } + } + .frame(height: 8) + } + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +#Preview { + let sampleData: [(category: String, cagr: Double, color: String)] = [ + ("Stocks", 12.5, "#10B981"), + ("Bonds", 4.2, "#3B82F6"), + ("Real Estate", 8.1, "#F59E0B"), + ("Crypto", -5.3, "#8B5CF6") + ] + + return VStack(spacing: 20) { + PerformanceBarChart(data: sampleData) + HorizontalPerformanceChart(data: sampleData) + } + .padding() +} diff --git a/InvestmentTracker/Views/Charts/PredictionChartView.swift b/InvestmentTracker/Views/Charts/PredictionChartView.swift new file mode 100644 index 0000000..9e94004 --- /dev/null +++ b/InvestmentTracker/Views/Charts/PredictionChartView.swift @@ -0,0 +1,251 @@ +import SwiftUI +import Charts + +struct PredictionChartView: View { + let predictions: [Prediction] + let historicalData: [(date: Date, value: Decimal)] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("12-Month Prediction") + .font(.headline) + + Spacer() + + if let lastPrediction = predictions.last { + VStack(alignment: .trailing, spacing: 2) { + Text("Forecast") + .font(.caption) + .foregroundColor(.secondary) + Text(lastPrediction.formattedValue) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.appPrimary) + } + } + } + + if let algorithm = predictions.first?.algorithm { + Text("Algorithm: \(algorithm.displayName)") + .font(.caption) + .foregroundColor(.secondary) + } + + if !predictions.isEmpty && historicalData.count >= 2 { + Chart { + // Historical data + ForEach(historicalData, id: \.date) { item in + LineMark( + x: .value("Date", item.date), + y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) + ) + .foregroundStyle(Color.appPrimary) + .interpolationMethod(.catmullRom) + } + + // Confidence interval area + ForEach(predictions) { prediction in + AreaMark( + x: .value("Date", prediction.date), + yStart: .value("Lower", NSDecimalNumber(decimal: prediction.confidenceInterval.lower).doubleValue), + yEnd: .value("Upper", NSDecimalNumber(decimal: prediction.confidenceInterval.upper).doubleValue) + ) + .foregroundStyle(Color.appSecondary.opacity(0.2)) + } + + // Prediction line + ForEach(predictions) { prediction in + LineMark( + x: .value("Date", prediction.date), + y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue) + ) + .foregroundStyle(Color.appSecondary) + .lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5])) + } + + // Connect historical to prediction + if let lastHistorical = historicalData.last, + let firstPrediction = predictions.first { + LineMark( + x: .value("Date", lastHistorical.date), + y: .value("Value", NSDecimalNumber(decimal: lastHistorical.value).doubleValue), + series: .value("Connection", "connect") + ) + .foregroundStyle(Color.appSecondary) + .lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5])) + + LineMark( + x: .value("Date", firstPrediction.date), + y: .value("Value", NSDecimalNumber(decimal: firstPrediction.predictedValue).doubleValue), + series: .value("Connection", "connect") + ) + .foregroundStyle(Color.appSecondary) + .lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5])) + } + } + .chartXAxis { + AxisMarks(values: .stride(by: .month, count: 3)) { value in + AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits)) + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisValueLabel { + if let doubleValue = value.as(Double.self) { + Text(Decimal(doubleValue).shortCurrencyString) + .font(.caption) + } + } + } + } + .frame(height: 280) + + // Legend + HStack(spacing: 20) { + HStack(spacing: 6) { + Rectangle() + .fill(Color.appPrimary) + .frame(width: 20, height: 3) + Text("Historical") + .font(.caption) + .foregroundColor(.secondary) + } + + HStack(spacing: 6) { + Rectangle() + .fill(Color.appSecondary) + .frame(width: 20, height: 3) + .mask( + HStack(spacing: 2) { + ForEach(0..<5, id: \.self) { _ in + Rectangle() + .frame(width: 3) + } + } + ) + Text("Prediction") + .font(.caption) + .foregroundColor(.secondary) + } + + HStack(spacing: 6) { + Rectangle() + .fill(Color.appSecondary.opacity(0.3)) + .frame(width: 20, height: 10) + Text("Confidence") + .font(.caption) + .foregroundColor(.secondary) + } + } + + // Prediction details + if let lastPrediction = predictions.last { + Divider() + .padding(.vertical, 8) + + VStack(spacing: 12) { + HStack { + Text("12-Month Forecast") + .font(.subheadline) + Spacer() + Text(lastPrediction.formattedValue) + .font(.subheadline.weight(.semibold)) + } + + HStack { + Text("Confidence Range") + .font(.subheadline) + Spacer() + Text(lastPrediction.formattedConfidenceRange) + .font(.caption) + .foregroundColor(.secondary) + } + + if let currentValue = historicalData.last?.value { + let change = lastPrediction.predictedValue - currentValue + let changePercent = currentValue > 0 + ? NSDecimalNumber(decimal: change / currentValue).doubleValue * 100 + : 0 + + HStack { + Text("Expected Change") + .font(.subheadline) + Spacer() + HStack(spacing: 4) { + Image(systemName: change >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.caption) + Text(String(format: "%+.1f%%", changePercent)) + .font(.subheadline.weight(.medium)) + } + .foregroundColor(change >= 0 ? .positiveGreen : .negativeRed) + } + } + } + } + } else { + VStack(spacing: 12) { + Image(systemName: "wand.and.stars") + .font(.system(size: 40)) + .foregroundColor(.secondary) + + Text("Not enough data for predictions") + .font(.subheadline) + .foregroundColor(.secondary) + + Text("Add at least 3 snapshots to generate predictions") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(height: 280) + .frame(maxWidth: .infinity) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +#Preview { + let historicalData: [(date: Date, value: Decimal)] = [ + (Date().adding(months: -6), 10000), + (Date().adding(months: -5), 10500), + (Date().adding(months: -4), 10200), + (Date().adding(months: -3), 11000), + (Date().adding(months: -2), 11500), + (Date().adding(months: -1), 11200), + (Date(), 12000) + ] + + let predictions = [ + Prediction( + date: Date().adding(months: 3), + predictedValue: 13000, + algorithm: .linear, + confidenceInterval: Prediction.ConfidenceInterval(lower: 12000, upper: 14000) + ), + Prediction( + date: Date().adding(months: 6), + predictedValue: 14000, + algorithm: .linear, + confidenceInterval: Prediction.ConfidenceInterval(lower: 12500, upper: 15500) + ), + Prediction( + date: Date().adding(months: 9), + predictedValue: 15000, + algorithm: .linear, + confidenceInterval: Prediction.ConfidenceInterval(lower: 13000, upper: 17000) + ), + Prediction( + date: Date().adding(months: 12), + predictedValue: 16000, + algorithm: .linear, + confidenceInterval: Prediction.ConfidenceInterval(lower: 13500, upper: 18500) + ) + ] + + return PredictionChartView(predictions: predictions, historicalData: historicalData) + .padding() +} diff --git a/InvestmentTracker/Views/Components/LoadingView.swift b/InvestmentTracker/Views/Components/LoadingView.swift new file mode 100644 index 0000000..b1a7cfc --- /dev/null +++ b/InvestmentTracker/Views/Components/LoadingView.swift @@ -0,0 +1,260 @@ +import SwiftUI + +struct LoadingView: View { + var message: String = "Loading..." + + var body: some View { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.5) + + Text(message) + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemBackground)) + } +} + +// MARK: - Skeleton Loading + +struct SkeletonView: View { + @State private var isAnimating = false + + var body: some View { + Rectangle() + .fill( + LinearGradient( + colors: [ + Color.gray.opacity(0.1), + Color.gray.opacity(0.2), + Color.gray.opacity(0.1) + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + .offset(x: isAnimating ? 200 : -200) + .animation( + Animation.linear(duration: 1.5) + .repeatForever(autoreverses: false), + value: isAnimating + ) + .mask(Rectangle()) + .onAppear { + isAnimating = true + } + } +} + +struct SkeletonCardView: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SkeletonView() + .frame(width: 100, height: 16) + .cornerRadius(4) + + SkeletonView() + .frame(height: 32) + .cornerRadius(4) + + HStack { + SkeletonView() + .frame(width: 80, height: 14) + .cornerRadius(4) + + Spacer() + + SkeletonView() + .frame(width: 60, height: 14) + .cornerRadius(4) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + } +} + +// MARK: - Empty State View + +struct EmptyStateView: View { + let icon: String + let title: String + let message: String + var actionTitle: String? + var action: (() -> Void)? + + var body: some View { + VStack(spacing: 20) { + Image(systemName: icon) + .font(.system(size: 60)) + .foregroundColor(.secondary) + + Text(title) + .font(.title2.weight(.semibold)) + + Text(message) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + if let actionTitle = actionTitle, let action = action { + Button(action: action) { + Text(actionTitle) + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(maxWidth: 200) + .background(Color.appPrimary) + .cornerRadius(AppConstants.UI.cornerRadius) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Error View + +struct ErrorView: View { + let message: String + var retryAction: (() -> Void)? + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 48)) + .foregroundColor(.appWarning) + + Text("Something went wrong") + .font(.headline) + + Text(message) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + if let retryAction = retryAction { + Button(action: retryAction) { + Label("Try Again", systemImage: "arrow.clockwise") + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Color.appPrimary) + .foregroundColor(.white) + .cornerRadius(20) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Success View + +struct SuccessView: View { + let title: String + let message: String + var action: (() -> Void)? + + var body: some View { + VStack(spacing: 20) { + ZStack { + Circle() + .fill(Color.positiveGreen.opacity(0.1)) + .frame(width: 100, height: 100) + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.positiveGreen) + } + + Text(title) + .font(.title2.weight(.bold)) + + Text(message) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + if let action = action { + Button(action: action) { + Text("Continue") + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(maxWidth: 200) + .background(Color.appPrimary) + .cornerRadius(AppConstants.UI.cornerRadius) + } + } + } + } +} + +// MARK: - Toast View + +struct ToastView: View { + enum ToastType { + case success, error, info + + var icon: String { + switch self { + case .success: return "checkmark.circle.fill" + case .error: return "xmark.circle.fill" + case .info: return "info.circle.fill" + } + } + + var color: Color { + switch self { + case .success: return .positiveGreen + case .error: return .negativeRed + case .info: return .appPrimary + } + } + } + + let message: String + let type: ToastType + + var body: some View { + HStack(spacing: 12) { + Image(systemName: type.icon) + .foregroundColor(type.color) + + Text(message) + .font(.subheadline) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.1), radius: 10, y: 5) + } +} + +#Preview { + VStack(spacing: 20) { + LoadingView() + .frame(height: 100) + + SkeletonCardView() + + EmptyStateView( + icon: "tray", + title: "No Data", + message: "Start adding your investments to see them here.", + actionTitle: "Get Started", + action: {} + ) + .frame(height: 300) + + ToastView(message: "Successfully saved!", type: .success) + } + .padding() +} diff --git a/InvestmentTracker/Views/Dashboard/CategoryBreakdown.swift b/InvestmentTracker/Views/Dashboard/CategoryBreakdown.swift new file mode 100644 index 0000000..3167d24 --- /dev/null +++ b/InvestmentTracker/Views/Dashboard/CategoryBreakdown.swift @@ -0,0 +1,191 @@ +import SwiftUI + +struct CategoryBreakdownCard: View { + let categories: [CategoryMetrics] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("By Category") + .font(.headline) + + Spacer() + + NavigationLink { + ChartsContainerView() + } label: { + Text("See All") + .font(.subheadline) + .foregroundColor(.appPrimary) + } + } + + ForEach(categories) { category in + CategoryRowView(category: category) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +struct CategoryRowView: View { + let category: CategoryMetrics + + var body: some View { + HStack(spacing: 12) { + // Icon + ZStack { + Circle() + .fill((Color(hex: category.colorHex) ?? .gray).opacity(0.2)) + .frame(width: 36, height: 36) + + Image(systemName: category.icon) + .font(.system(size: 14)) + .foregroundColor(Color(hex: category.colorHex) ?? .gray) + } + + // Name and percentage + VStack(alignment: .leading, spacing: 2) { + Text(category.categoryName) + .font(.subheadline.weight(.medium)) + + Text(category.formattedPercentage) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + // Value and return + VStack(alignment: .trailing, spacing: 2) { + Text(category.formattedTotalValue) + .font(.subheadline.weight(.semibold)) + + HStack(spacing: 2) { + Image(systemName: category.metrics.cagr >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + Text(category.metrics.formattedCAGR) + .font(.caption) + } + .foregroundColor(category.metrics.cagr >= 0 ? .positiveGreen : .negativeRed) + } + } + .padding(.vertical, 4) + } +} + +// MARK: - Category Progress Bar + +struct CategoryProgressBar: View { + let category: CategoryMetrics + let maxValue: Decimal + + var progress: Double { + guard maxValue > 0 else { return 0 } + return NSDecimalNumber(decimal: category.totalValue / maxValue).doubleValue + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + HStack(spacing: 6) { + Circle() + .fill(Color(hex: category.colorHex) ?? .gray) + .frame(width: 8, height: 8) + + Text(category.categoryName) + .font(.caption) + } + + Spacer() + + Text(category.formattedPercentage) + .font(.caption) + .foregroundColor(.secondary) + } + + GeometryReader { geometry in + ZStack(alignment: .leading) { + Rectangle() + .fill(Color.gray.opacity(0.1)) + .frame(height: 6) + .cornerRadius(3) + + Rectangle() + .fill(Color(hex: category.colorHex) ?? .gray) + .frame(width: geometry.size.width * progress, height: 6) + .cornerRadius(3) + } + } + .frame(height: 6) + } + } +} + +// MARK: - Simple Category List + +struct SimpleCategoryList: View { + let categories: [CategoryMetrics] + + var body: some View { + VStack(spacing: 8) { + ForEach(categories) { category in + HStack { + Circle() + .fill(Color(hex: category.colorHex) ?? .gray) + .frame(width: 10, height: 10) + + Text(category.categoryName) + .font(.subheadline) + + Spacer() + + Text(category.formattedTotalValue) + .font(.subheadline.weight(.medium)) + + Text("(\(category.formattedPercentage))") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } +} + +#Preview { + let sampleCategories = [ + CategoryMetrics( + id: UUID(), + categoryName: "Stocks", + colorHex: "#10B981", + icon: "chart.line.uptrend.xyaxis", + totalValue: 50000, + percentageOfPortfolio: 50, + metrics: .empty + ), + CategoryMetrics( + id: UUID(), + categoryName: "Bonds", + colorHex: "#3B82F6", + icon: "building.columns.fill", + totalValue: 30000, + percentageOfPortfolio: 30, + metrics: .empty + ), + CategoryMetrics( + id: UUID(), + categoryName: "Real Estate", + colorHex: "#F59E0B", + icon: "house.fill", + totalValue: 20000, + percentageOfPortfolio: 20, + metrics: .empty + ) + ] + + return CategoryBreakdownCard(categories: sampleCategories) + .padding() +} diff --git a/InvestmentTracker/Views/Dashboard/DashboardView.swift b/InvestmentTracker/Views/Dashboard/DashboardView.swift new file mode 100644 index 0000000..39bd424 --- /dev/null +++ b/InvestmentTracker/Views/Dashboard/DashboardView.swift @@ -0,0 +1,262 @@ +import SwiftUI +import Charts + +struct DashboardView: View { + @EnvironmentObject var iapService: IAPService + @StateObject private var viewModel: DashboardViewModel + + init() { + _viewModel = StateObject(wrappedValue: DashboardViewModel()) + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 20) { + if viewModel.hasData { + // Total Value Card + TotalValueCard( + totalValue: viewModel.portfolioSummary.formattedTotalValue, + dayChange: viewModel.portfolioSummary.formattedDayChange, + isPositive: viewModel.isDayChangePositive + ) + + // Evolution Chart + if !viewModel.evolutionData.isEmpty { + EvolutionChartCard(data: viewModel.evolutionData) + } + + // Period Returns + PeriodReturnsCard( + monthChange: viewModel.portfolioSummary.formattedMonthChange, + yearChange: viewModel.portfolioSummary.formattedYearChange, + allTimeChange: viewModel.portfolioSummary.formattedAllTimeReturn, + isMonthPositive: viewModel.isMonthChangePositive, + isYearPositive: viewModel.isYearChangePositive, + isAllTimePositive: viewModel.portfolioSummary.allTimeReturn >= 0 + ) + + // Category Breakdown + if !viewModel.categoryMetrics.isEmpty { + CategoryBreakdownCard(categories: viewModel.topCategories) + } + + // Pending Updates + if !viewModel.sourcesNeedingUpdate.isEmpty { + PendingUpdatesCard(sources: viewModel.sourcesNeedingUpdate) + } + } else { + EmptyDashboardView() + } + } + .padding() + } + .navigationTitle("Dashboard") + .refreshable { + viewModel.refreshData() + } + .overlay { + if viewModel.isLoading { + ProgressView() + } + } + } + } +} + +// MARK: - Total Value Card + +struct TotalValueCard: View { + let totalValue: String + let dayChange: String + let isPositive: Bool + + var body: some View { + VStack(spacing: 8) { + Text("Total Portfolio Value") + .font(.subheadline) + .foregroundColor(.secondary) + + Text(totalValue) + .font(.system(size: 42, weight: .bold, design: .rounded)) + .foregroundColor(.primary) + + HStack(spacing: 4) { + Image(systemName: isPositive ? "arrow.up.right" : "arrow.down.right") + .font(.caption) + Text(dayChange) + .font(.subheadline.weight(.medium)) + Text("today") + .font(.subheadline) + .foregroundColor(.secondary) + } + .foregroundColor(isPositive ? .positiveGreen : .negativeRed) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +// MARK: - Period Returns Card + +struct PeriodReturnsCard: View { + let monthChange: String + let yearChange: String + let allTimeChange: String + let isMonthPositive: Bool + let isYearPositive: Bool + let isAllTimePositive: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Returns") + .font(.headline) + + HStack(spacing: 16) { + ReturnPeriodView( + period: "1M", + change: monthChange, + isPositive: isMonthPositive + ) + + Divider() + + ReturnPeriodView( + period: "1Y", + change: yearChange, + isPositive: isYearPositive + ) + + Divider() + + ReturnPeriodView( + period: "All", + change: allTimeChange, + isPositive: isAllTimePositive + ) + } + .frame(maxWidth: .infinity) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +struct ReturnPeriodView: View { + let period: String + let change: String + let isPositive: Bool + + var body: some View { + VStack(spacing: 4) { + Text(period) + .font(.caption) + .foregroundColor(.secondary) + + Text(change) + .font(.subheadline.weight(.semibold)) + .foregroundColor(isPositive ? .positiveGreen : .negativeRed) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity) + } +} + +// MARK: - Empty Dashboard View + +struct EmptyDashboardView: View { + var body: some View { + VStack(spacing: 20) { + Image(systemName: "chart.pie") + .font(.system(size: 60)) + .foregroundColor(.secondary) + + Text("Welcome to Investment Tracker") + .font(.title2.weight(.semibold)) + + Text("Start by adding your first investment source to track your portfolio.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + NavigationLink { + AddSourceView() + } label: { + Label("Add Investment Source", systemImage: "plus") + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(maxWidth: .infinity) + .background(Color.appPrimary) + .cornerRadius(AppConstants.UI.cornerRadius) + } + } + .padding() + } +} + +// MARK: - Pending Updates Card + +struct PendingUpdatesCard: View { + let sources: [InvestmentSource] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "bell.badge.fill") + .foregroundColor(.appWarning) + Text("Pending Updates") + .font(.headline) + Spacer() + Text("\(sources.count)") + .font(.subheadline) + .foregroundColor(.secondary) + } + + ForEach(sources.prefix(3)) { source in + NavigationLink(destination: SourceDetailView(source: source)) { + HStack { + Circle() + .fill(source.category?.color ?? .gray) + .frame(width: 8, height: 8) + + Text(source.name) + .font(.subheadline) + + Spacer() + + Text(source.latestSnapshot?.date.relativeDescription ?? "Never") + .font(.caption) + .foregroundColor(.secondary) + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + } + .buttonStyle(.plain) + } + + if sources.count > 3 { + Text("+ \(sources.count - 3) more") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +#Preview { + DashboardView() + .environmentObject(IAPService()) +} diff --git a/InvestmentTracker/Views/Dashboard/EvolutionChart.swift b/InvestmentTracker/Views/Dashboard/EvolutionChart.swift new file mode 100644 index 0000000..45e2853 --- /dev/null +++ b/InvestmentTracker/Views/Dashboard/EvolutionChart.swift @@ -0,0 +1,158 @@ +import SwiftUI +import Charts + +struct EvolutionChartCard: View { + let data: [(date: Date, value: Decimal)] + + @State private var selectedDataPoint: (date: Date, value: Decimal)? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Portfolio Evolution") + .font(.headline) + + Spacer() + + if let selected = selectedDataPoint { + VStack(alignment: .trailing) { + Text(selected.value.compactCurrencyString) + .font(.subheadline.weight(.semibold)) + Text(selected.date.monthYearString) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + if data.count >= 2 { + Chart { + ForEach(data, id: \.date) { item in + LineMark( + x: .value("Date", item.date), + y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) + ) + .foregroundStyle(Color.appPrimary) + .interpolationMethod(.catmullRom) + + AreaMark( + x: .value("Date", item.date), + y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) + ) + .foregroundStyle( + LinearGradient( + colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)], + startPoint: .top, + endPoint: .bottom + ) + ) + .interpolationMethod(.catmullRom) + } + + if let selected = selectedDataPoint { + RuleMark(x: .value("Selected", selected.date)) + .foregroundStyle(Color.gray.opacity(0.3)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5])) + + PointMark( + x: .value("Date", selected.date), + y: .value("Value", NSDecimalNumber(decimal: selected.value).doubleValue) + ) + .foregroundStyle(Color.appPrimary) + .symbolSize(100) + } + } + .chartXAxis { + AxisMarks(values: .stride(by: .month, count: 3)) { value in + AxisValueLabel(format: .dateTime.month(.abbreviated)) + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisValueLabel { + if let doubleValue = value.as(Double.self) { + Text(Decimal(doubleValue).shortCurrencyString) + .font(.caption) + } + } + } + } + .chartOverlay { proxy in + GeometryReader { geometry in + Rectangle() + .fill(.clear) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let x = value.location.x - geometry[proxy.plotAreaFrame].origin.x + guard let date: Date = proxy.value(atX: x) else { return } + + if let closest = data.min(by: { + abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) + }) { + selectedDataPoint = closest + } + } + .onEnded { _ in + selectedDataPoint = nil + } + ) + } + } + .frame(height: 200) + } else { + Text("Not enough data to display chart") + .font(.subheadline) + .foregroundColor(.secondary) + .frame(height: 200) + .frame(maxWidth: .infinity) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +// MARK: - Mini Sparkline + +struct SparklineView: View { + let data: [(date: Date, value: Decimal)] + let color: Color + + var body: some View { + if data.count >= 2 { + Chart(data, id: \.date) { item in + LineMark( + x: .value("Date", item.date), + y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) + ) + .foregroundStyle(color) + .interpolationMethod(.catmullRom) + } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartLegend(.hidden) + } else { + Rectangle() + .fill(Color.gray.opacity(0.1)) + } + } +} + +#Preview { + let sampleData: [(date: Date, value: Decimal)] = [ + (Date().adding(months: -6), 10000), + (Date().adding(months: -5), 10500), + (Date().adding(months: -4), 10200), + (Date().adding(months: -3), 11000), + (Date().adding(months: -2), 11500), + (Date().adding(months: -1), 11200), + (Date(), 12000) + ] + + return EvolutionChartCard(data: sampleData) + .padding() +} diff --git a/InvestmentTracker/Views/Onboarding/OnboardingView.swift b/InvestmentTracker/Views/Onboarding/OnboardingView.swift new file mode 100644 index 0000000..d358928 --- /dev/null +++ b/InvestmentTracker/Views/Onboarding/OnboardingView.swift @@ -0,0 +1,230 @@ +import SwiftUI + +struct OnboardingView: View { + @Binding var onboardingCompleted: Bool + + @State private var currentPage = 0 + + private let pages: [OnboardingPage] = [ + OnboardingPage( + icon: "chart.pie.fill", + title: "Track Your Investments", + description: "Monitor all your investment sources in one place. Stocks, bonds, real estate, crypto, and more.", + color: .appPrimary + ), + OnboardingPage( + icon: "chart.line.uptrend.xyaxis", + title: "Visualize Your Growth", + description: "Beautiful charts show your portfolio evolution, allocation, and performance over time.", + color: .positiveGreen + ), + OnboardingPage( + icon: "bell.badge.fill", + title: "Never Miss an Update", + description: "Set reminders to track your investments regularly. Monthly, quarterly, or custom schedules.", + color: .appWarning + ), + OnboardingPage( + icon: "icloud.fill", + title: "Sync Everywhere", + description: "Your data syncs automatically via iCloud across all your Apple devices.", + color: .appSecondary + ) + ] + + var body: some View { + VStack { + // Pages + TabView(selection: $currentPage) { + ForEach(0.. 1 ? "s" : "")", + value: $customFrequencyMonths, + in: 1...24 + ) + } + } header: { + Text("Reminders") + } footer: { + Text("We'll remind you to update this investment based on your selected frequency.") + } + } + .navigationTitle("Add Source") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Add") { + saveSource() + } + .disabled(!isValid) + .fontWeight(.semibold) + } + } + .sheet(isPresented: $showingCategoryPicker) { + CategoryPickerView( + selectedCategory: $selectedCategory, + categories: categoryRepository.categories + ) + } + .onAppear { + categoryRepository.createDefaultCategoriesIfNeeded() + if selectedCategory == nil { + selectedCategory = categoryRepository.categories.first + } + } + } + } + + // MARK: - Validation + + private var isValid: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil + } + + // MARK: - Save + + private func saveSource() { + guard let category = selectedCategory else { return } + + let repository = InvestmentSourceRepository() + let source = repository.createSource( + name: name.trimmingCharacters(in: .whitespaces), + category: category, + notificationFrequency: notificationFrequency, + customFrequencyMonths: customFrequencyMonths + ) + + // Add initial snapshot if provided + if let value = parseDecimal(initialValue), value > 0 { + let snapshotRepository = SnapshotRepository() + snapshotRepository.createSnapshot( + for: source, + date: Date(), + value: value + ) + } + + // Schedule notification + NotificationService.shared.scheduleReminder(for: source) + + // Log analytics + FirebaseService.shared.logSourceAdded( + categoryName: category.name, + sourceCount: repository.sourceCount + ) + + dismiss() + } + + private func parseDecimal(_ string: String) -> Decimal? { + let cleaned = string + .replacingOccurrences(of: "€", with: "") + .replacingOccurrences(of: ",", with: ".") + .trimmingCharacters(in: .whitespaces) + + guard !cleaned.isEmpty else { return nil } + + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = Locale(identifier: "en_US") + + return formatter.number(from: cleaned)?.decimalValue + } +} + +// MARK: - Category Picker View + +struct CategoryPickerView: View { + @Environment(\.dismiss) private var dismiss + @Binding var selectedCategory: Category? + let categories: [Category] + + var body: some View { + NavigationStack { + List(categories) { category in + Button { + selectedCategory = category + dismiss() + } label: { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(category.color.opacity(0.2)) + .frame(width: 40, height: 40) + + Image(systemName: category.icon) + .foregroundColor(category.color) + } + + Text(category.name) + .foregroundColor(.primary) + + Spacer() + + if selectedCategory?.id == category.id { + Image(systemName: "checkmark") + .foregroundColor(.appPrimary) + } + } + } + } + .navigationTitle("Select Category") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + } +} + +// MARK: - Edit Source View + +struct EditSourceView: View { + @Environment(\.dismiss) private var dismiss + let source: InvestmentSource + + @State private var name: String + @State private var selectedCategory: Category? + @State private var notificationFrequency: NotificationFrequency + @State private var customFrequencyMonths: Int + @State private var showingCategoryPicker = false + + @StateObject private var categoryRepository = CategoryRepository() + + init(source: InvestmentSource) { + self.source = source + _name = State(initialValue: source.name) + _selectedCategory = State(initialValue: source.category) + _notificationFrequency = State(initialValue: source.frequency) + _customFrequencyMonths = State(initialValue: Int(source.customFrequencyMonths)) + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Source Name", text: $name) + + Button { + showingCategoryPicker = true + } label: { + HStack { + Text("Category") + .foregroundColor(.primary) + + Spacer() + + if let category = selectedCategory { + HStack(spacing: 6) { + Image(systemName: category.icon) + .foregroundColor(category.color) + Text(category.name) + .foregroundColor(.secondary) + } + } + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + Section { + Picker("Reminder Frequency", selection: $notificationFrequency) { + ForEach(NotificationFrequency.allCases) { frequency in + Text(frequency.displayName).tag(frequency) + } + } + + if notificationFrequency == .custom { + Stepper( + "Every \(customFrequencyMonths) month\(customFrequencyMonths > 1 ? "s" : "")", + value: $customFrequencyMonths, + in: 1...24 + ) + } + } header: { + Text("Reminders") + } + } + .navigationTitle("Edit Source") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + saveChanges() + } + .disabled(!isValid) + .fontWeight(.semibold) + } + } + .sheet(isPresented: $showingCategoryPicker) { + CategoryPickerView( + selectedCategory: $selectedCategory, + categories: categoryRepository.categories + ) + } + } + } + + private var isValid: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty && selectedCategory != nil + } + + private func saveChanges() { + guard let category = selectedCategory else { return } + + let repository = InvestmentSourceRepository() + repository.updateSource( + source, + name: name.trimmingCharacters(in: .whitespaces), + category: category, + notificationFrequency: notificationFrequency, + customFrequencyMonths: customFrequencyMonths + ) + + // Reschedule notification + NotificationService.shared.scheduleReminder(for: source) + + dismiss() + } +} + +// MARK: - Add Snapshot View + +struct AddSnapshotView: View { + @Environment(\.dismiss) private var dismiss + let source: InvestmentSource + + @StateObject private var viewModel: SnapshotFormViewModel + + init(source: InvestmentSource) { + self.source = source + _viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source)) + } + + var body: some View { + NavigationStack { + Form { + Section { + DatePicker( + "Date", + selection: $viewModel.date, + in: ...Date(), + displayedComponents: .date + ) + + HStack { + Text("€") + .foregroundColor(.secondary) + + TextField("Value", text: $viewModel.valueString) + .keyboardType(.decimalPad) + } + + if let previous = viewModel.previousValueString { + Text(previous) + .font(.caption) + .foregroundColor(.secondary) + } + } header: { + Text("Snapshot Details") + } + + // Change preview + if let change = viewModel.formattedChange, + let percentage = viewModel.formattedChangePercentage { + Section { + HStack { + Text("Change from previous") + Spacer() + Text("\(change) (\(percentage))") + .foregroundColor( + (viewModel.changeFromPrevious ?? 0) >= 0 + ? .positiveGreen + : .negativeRed + ) + } + } + } + + Section { + Toggle("Include Contribution", isOn: $viewModel.includeContribution) + + if viewModel.includeContribution { + HStack { + Text("€") + .foregroundColor(.secondary) + + TextField("New capital added", text: $viewModel.contributionString) + .keyboardType(.decimalPad) + } + } + } header: { + Text("Contribution (Optional)") + } footer: { + Text("Track new capital you've added to separate it from investment growth.") + } + + Section { + TextField("Notes", text: $viewModel.notes, axis: .vertical) + .lineLimit(3...6) + } header: { + Text("Notes (Optional)") + } + } + .navigationTitle(viewModel.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button(viewModel.buttonTitle) { + saveSnapshot() + } + .disabled(!viewModel.isValid) + .fontWeight(.semibold) + } + } + } + } + + private func saveSnapshot() { + guard let value = viewModel.value else { return } + + let repository = SnapshotRepository() + repository.createSnapshot( + for: source, + date: viewModel.date, + value: value, + contribution: viewModel.contribution, + notes: viewModel.notes.isEmpty ? nil : viewModel.notes + ) + + // Reschedule notification + NotificationService.shared.scheduleReminder(for: source) + + // Log analytics + FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value) + + dismiss() + } +} + +#Preview { + AddSourceView() + .environmentObject(IAPService()) +} diff --git a/InvestmentTracker/Views/Sources/SourceDetailView.swift b/InvestmentTracker/Views/Sources/SourceDetailView.swift new file mode 100644 index 0000000..e06ce43 --- /dev/null +++ b/InvestmentTracker/Views/Sources/SourceDetailView.swift @@ -0,0 +1,426 @@ +import SwiftUI +import Charts + +struct SourceDetailView: View { + @EnvironmentObject var iapService: IAPService + @StateObject private var viewModel: SourceDetailViewModel + @Environment(\.dismiss) private var dismiss + + @State private var showingDeleteConfirmation = false + + init(source: InvestmentSource) { + _viewModel = StateObject(wrappedValue: SourceDetailViewModel( + source: source, + iapService: IAPService() + )) + } + + var body: some View { + ScrollView { + VStack(spacing: 20) { + // Header Card + headerCard + + // Quick Actions + quickActions + + // Chart + if !viewModel.chartData.isEmpty { + chartSection + } + + // Metrics + metricsSection + + // Predictions (Premium) + if viewModel.hasEnoughDataForPredictions { + predictionsSection + } + + // Snapshots List + snapshotsSection + } + .padding() + } + .navigationTitle(viewModel.source.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Button { + viewModel.showingEditSource = true + } label: { + Label("Edit Source", systemImage: "pencil") + } + + Button(role: .destructive) { + showingDeleteConfirmation = true + } label: { + Label("Delete Source", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .sheet(isPresented: $viewModel.showingAddSnapshot) { + AddSnapshotView(source: viewModel.source) + } + .sheet(isPresented: $viewModel.showingEditSource) { + EditSourceView(source: viewModel.source) + } + .sheet(isPresented: $viewModel.showingPaywall) { + PaywallView() + } + .confirmationDialog( + "Delete Source", + isPresented: $showingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + // Delete and dismiss + let repository = InvestmentSourceRepository() + repository.deleteSource(viewModel.source) + dismiss() + } + } message: { + Text("This will permanently delete \(viewModel.source.name) and all its snapshots.") + } + } + + // MARK: - Header Card + + private var headerCard: some View { + VStack(spacing: 12) { + // Category badge + HStack { + Image(systemName: viewModel.source.category?.icon ?? "questionmark") + Text(viewModel.categoryName) + } + .font(.caption) + .foregroundColor(Color(hex: viewModel.categoryColor) ?? .gray) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background((Color(hex: viewModel.categoryColor) ?? .gray).opacity(0.1)) + .cornerRadius(20) + + // Current value + Text(viewModel.formattedCurrentValue) + .font(.system(size: 36, weight: .bold, design: .rounded)) + + // Return + HStack(spacing: 4) { + Image(systemName: viewModel.isPositiveReturn ? "arrow.up.right" : "arrow.down.right") + Text(viewModel.formattedTotalReturn) + Text("(\(viewModel.formattedPercentageReturn))") + } + .font(.subheadline.weight(.medium)) + .foregroundColor(viewModel.isPositiveReturn ? .positiveGreen : .negativeRed) + + // Last updated + Text("Last updated: \(viewModel.lastUpdated)") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } + + // MARK: - Quick Actions + + private var quickActions: some View { + HStack(spacing: 12) { + Button { + viewModel.showingAddSnapshot = true + } label: { + Label("Add Snapshot", systemImage: "plus.circle.fill") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding() + .background(Color.appPrimary) + .foregroundColor(.white) + .cornerRadius(AppConstants.UI.cornerRadius) + } + } + } + + // MARK: - Chart Section + + private var chartSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Value History") + .font(.headline) + + Chart(viewModel.chartData, id: \.date) { item in + LineMark( + x: .value("Date", item.date), + y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) + ) + .foregroundStyle(Color.appPrimary) + .interpolationMethod(.catmullRom) + + AreaMark( + x: .value("Date", item.date), + y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue) + ) + .foregroundStyle( + LinearGradient( + colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)], + startPoint: .top, + endPoint: .bottom + ) + ) + .interpolationMethod(.catmullRom) + } + .chartXAxis { + AxisMarks(values: .stride(by: .month, count: 2)) { value in + AxisValueLabel(format: .dateTime.month(.abbreviated)) + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisValueLabel { + if let doubleValue = value.as(Double.self) { + Text(Decimal(doubleValue).shortCurrencyString) + .font(.caption) + } + } + } + } + .frame(height: 200) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } + + // MARK: - Metrics Section + + private var metricsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Performance Metrics") + .font(.headline) + + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 16) { + MetricCard(title: "CAGR", value: viewModel.metrics.formattedCAGR) + MetricCard(title: "Volatility", value: viewModel.metrics.formattedVolatility) + MetricCard(title: "Max Drawdown", value: viewModel.metrics.formattedMaxDrawdown) + MetricCard(title: "Sharpe Ratio", value: viewModel.metrics.formattedSharpeRatio) + MetricCard(title: "Win Rate", value: viewModel.metrics.formattedWinRate) + MetricCard(title: "Avg Monthly", value: viewModel.metrics.formattedAverageMonthlyReturn) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } + + // MARK: - Predictions Section + + private var predictionsSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Predictions") + .font(.headline) + + Spacer() + + if !viewModel.canViewPredictions { + Label("Premium", systemImage: "crown.fill") + .font(.caption) + .foregroundColor(.appWarning) + } + } + + if viewModel.canViewPredictions { + if let result = viewModel.predictionResult, !viewModel.predictions.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Algorithm: \(result.algorithm.displayName)") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + Text("Confidence: \(result.confidenceLevel.rawValue)") + .font(.caption) + .foregroundColor(Color(hex: result.confidenceLevel.color) ?? .gray) + } + + // Show 12-month prediction + if let lastPrediction = viewModel.predictions.last { + HStack { + VStack(alignment: .leading) { + Text("12-Month Forecast") + .font(.subheadline) + .foregroundColor(.secondary) + Text(lastPrediction.formattedValue) + .font(.title3.weight(.semibold)) + } + + Spacer() + + VStack(alignment: .trailing) { + Text("Range") + .font(.subheadline) + .foregroundColor(.secondary) + Text(lastPrediction.formattedConfidenceRange) + .font(.caption) + } + } + } + } + } + } else { + Button { + viewModel.showingPaywall = true + } label: { + HStack { + Image(systemName: "lock.fill") + Text("Unlock Predictions with Premium") + } + .font(.subheadline) + .frame(maxWidth: .infinity) + .padding() + .background(Color.appPrimary.opacity(0.1)) + .foregroundColor(.appPrimary) + .cornerRadius(AppConstants.UI.smallCornerRadius) + } + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } + + // MARK: - Snapshots Section + + private var snapshotsSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Snapshots") + .font(.headline) + + Spacer() + + Text("\(viewModel.snapshotCount)") + .font(.subheadline) + .foregroundColor(.secondary) + } + + if viewModel.isHistoryLimited { + HStack { + Image(systemName: "info.circle") + .foregroundColor(.appWarning) + Text("\(viewModel.hiddenSnapshotCount) older snapshots hidden. Upgrade for full history.") + .font(.caption) + + Spacer() + + Button("Upgrade") { + viewModel.showingPaywall = true + } + .font(.caption.weight(.semibold)) + } + .padding(8) + .background(Color.appWarning.opacity(0.1)) + .cornerRadius(AppConstants.UI.smallCornerRadius) + } + + ForEach(viewModel.snapshots.prefix(10)) { snapshot in + SnapshotRowView(snapshot: snapshot) + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + viewModel.deleteSnapshot(snapshot) + } label: { + Label("Delete", systemImage: "trash") + } + } + + if snapshot.id != viewModel.snapshots.prefix(10).last?.id { + Divider() + } + } + + if viewModel.snapshots.count > 10 { + Text("+ \(viewModel.snapshots.count - 10) more snapshots") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } +} + +// MARK: - Metric Card + +struct MetricCard: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundColor(.secondary) + + Text(value) + .font(.subheadline.weight(.semibold)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Snapshot Row View + +struct SnapshotRowView: View { + let snapshot: Snapshot + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(snapshot.date.friendlyDescription) + .font(.subheadline) + + if let notes = snapshot.notes, !notes.isEmpty { + Text(notes) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(snapshot.formattedValue) + .font(.subheadline.weight(.medium)) + + if snapshot.contribution != nil && snapshot.decimalContribution > 0 { + Text("+ \(snapshot.decimalContribution.currencyString)") + .font(.caption) + .foregroundColor(.appPrimary) + } + } + } + .padding(.vertical, 4) + } +} + +#Preview { + NavigationStack { + Text("Source Detail Preview") + } +} diff --git a/InvestmentTracker/Views/Sources/SourceListView.swift b/InvestmentTracker/Views/Sources/SourceListView.swift new file mode 100644 index 0000000..2e7c02d --- /dev/null +++ b/InvestmentTracker/Views/Sources/SourceListView.swift @@ -0,0 +1,261 @@ +import SwiftUI + +struct SourceListView: View { + @EnvironmentObject var iapService: IAPService + @StateObject private var viewModel: SourceListViewModel + + init() { + // We'll set the iapService in onAppear since we need @EnvironmentObject + _viewModel = StateObject(wrappedValue: SourceListViewModel(iapService: IAPService())) + } + + var body: some View { + NavigationStack { + Group { + if viewModel.isEmpty { + emptyStateView + } else { + sourcesList + } + } + .navigationTitle("Sources") + .searchable(text: $viewModel.searchText, prompt: "Search sources") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + viewModel.addSourceTapped() + } label: { + Image(systemName: "plus") + } + } + + ToolbarItem(placement: .navigationBarLeading) { + categoryFilterMenu + } + } + .sheet(isPresented: $viewModel.showingAddSource) { + AddSourceView() + } + .sheet(isPresented: $viewModel.showingPaywall) { + PaywallView() + } + } + } + + // MARK: - Sources List + + private var sourcesList: some View { + List { + // Summary Header + if !viewModel.isFiltered { + Section { + HStack { + VStack(alignment: .leading) { + Text("Total Value") + .font(.caption) + .foregroundColor(.secondary) + Text(viewModel.formattedTotalValue) + .font(.title2.weight(.bold)) + } + + Spacer() + + VStack(alignment: .trailing) { + Text("Sources") + .font(.caption) + .foregroundColor(.secondary) + Text("\(viewModel.sources.count)") + .font(.title2.weight(.bold)) + } + } + .padding(.vertical, 4) + } + } + + // Source limit warning + if viewModel.sourceLimitReached { + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.appWarning) + + Text("Source limit reached. Upgrade to Premium for unlimited sources.") + .font(.subheadline) + + Spacer() + + Button("Upgrade") { + viewModel.showingPaywall = true + } + .font(.subheadline.weight(.semibold)) + .foregroundColor(.appPrimary) + } + } + } + + // Sources + Section { + ForEach(viewModel.sources) { source in + NavigationLink { + SourceDetailView(source: source) + } label: { + SourceRowView(source: source) + } + } + .onDelete { indexSet in + viewModel.deleteSource(at: indexSet) + } + } header: { + if viewModel.isFiltered { + HStack { + Text("\(viewModel.sources.count) results") + Spacer() + Button("Clear Filters") { + viewModel.clearFilters() + } + .font(.caption) + } + } + } + } + .listStyle(.insetGrouped) + .refreshable { + viewModel.loadData() + } + } + + // MARK: - Empty State + + private var emptyStateView: some View { + VStack(spacing: 20) { + Image(systemName: "tray") + .font(.system(size: 60)) + .foregroundColor(.secondary) + + Text("No Investment Sources") + .font(.title2.weight(.semibold)) + + Text("Add your first investment source to start tracking your portfolio.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + Button { + viewModel.showingAddSource = true + } label: { + Label("Add Source", systemImage: "plus") + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(maxWidth: 200) + .background(Color.appPrimary) + .cornerRadius(AppConstants.UI.cornerRadius) + } + } + } + + // MARK: - Category Filter Menu + + private var categoryFilterMenu: some View { + Menu { + Button { + viewModel.selectCategory(nil) + } label: { + HStack { + Text("All Categories") + if viewModel.selectedCategory == nil { + Image(systemName: "checkmark") + } + } + } + + Divider() + + ForEach(viewModel.categories) { category in + Button { + viewModel.selectCategory(category) + } label: { + HStack { + Image(systemName: category.icon) + Text(category.name) + if viewModel.selectedCategory?.id == category.id { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 4) { + Image(systemName: "line.3.horizontal.decrease.circle") + if viewModel.selectedCategory != nil { + Circle() + .fill(Color.appPrimary) + .frame(width: 8, height: 8) + } + } + } + } +} + +// MARK: - Source Row View + +struct SourceRowView: View { + let source: InvestmentSource + + var body: some View { + HStack(spacing: 12) { + // Category indicator + ZStack { + Circle() + .fill((source.category?.color ?? .gray).opacity(0.2)) + .frame(width: 44, height: 44) + + Image(systemName: source.category?.icon ?? "questionmark") + .font(.system(size: 18)) + .foregroundColor(source.category?.color ?? .gray) + } + + // Source info + VStack(alignment: .leading, spacing: 4) { + Text(source.name) + .font(.headline) + + HStack(spacing: 8) { + Text(source.category?.name ?? "Uncategorized") + .font(.caption) + .foregroundColor(.secondary) + + if source.needsUpdate { + Label("Needs update", systemImage: "bell.badge.fill") + .font(.caption2) + .foregroundColor(.appWarning) + } + } + } + + Spacer() + + // Value + VStack(alignment: .trailing, spacing: 4) { + Text(source.latestValue.compactCurrencyString) + .font(.subheadline.weight(.semibold)) + + HStack(spacing: 2) { + Image(systemName: source.totalReturn >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + Text(String(format: "%.1f%%", NSDecimalNumber(decimal: source.totalReturn).doubleValue)) + .font(.caption) + } + .foregroundColor(source.totalReturn >= 0 ? .positiveGreen : .negativeRed) + } + } + .padding(.vertical, 4) + .opacity(source.isActive ? 1 : 0.5) + } +} + +#Preview { + SourceListView() + .environmentObject(IAPService()) +} diff --git a/InvestmentTrackerWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/InvestmentTrackerWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/InvestmentTrackerWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/InvestmentTrackerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/InvestmentTrackerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/InvestmentTrackerWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/InvestmentTrackerWidget/Assets.xcassets/Contents.json b/InvestmentTrackerWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/InvestmentTrackerWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/InvestmentTrackerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/InvestmentTrackerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/InvestmentTrackerWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/InvestmentTrackerWidget/InvestmentWidget.swift b/InvestmentTrackerWidget/InvestmentWidget.swift new file mode 100644 index 0000000..c3adda2 --- /dev/null +++ b/InvestmentTrackerWidget/InvestmentWidget.swift @@ -0,0 +1,299 @@ +import WidgetKit +import SwiftUI +import CoreData + +// MARK: - Widget Entry + +struct InvestmentWidgetEntry: TimelineEntry { + let date: Date + let totalValue: Decimal + let dayChange: Decimal + let dayChangePercentage: Double + let topSources: [(name: String, value: Decimal, color: String)] + let sparklineData: [Decimal] +} + +// MARK: - Widget Provider + +struct InvestmentWidgetProvider: TimelineProvider { + func placeholder(in context: Context) -> InvestmentWidgetEntry { + InvestmentWidgetEntry( + date: Date(), + totalValue: 50000, + dayChange: 250, + dayChangePercentage: 0.5, + topSources: [ + ("Stocks", 30000, "#10B981"), + ("Bonds", 15000, "#3B82F6"), + ("Real Estate", 5000, "#F59E0B") + ], + sparklineData: [45000, 46000, 47000, 48000, 49000, 50000] + ) + } + + func getSnapshot(in context: Context, completion: @escaping (InvestmentWidgetEntry) -> Void) { + let entry = fetchData() + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = fetchData() + + // Refresh every hour + let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date() + let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) + + completion(timeline) + } + + private func fetchData() -> InvestmentWidgetEntry { + let container = CoreDataStack.createWidgetContainer() + let context = container.viewContext + + // Fetch sources + let sourceRequest: NSFetchRequest = InvestmentSource.fetchRequest() + let sources = (try? context.fetch(sourceRequest)) ?? [] + + // Calculate total value + let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue } + + // Get top sources + let topSources = sources + .sorted { $0.latestValue > $1.latestValue } + .prefix(3) + .map { (name: $0.name, value: $0.latestValue, color: $0.category?.colorHex ?? "#3B82F6") } + + // Calculate day change (simplified - would need proper historical data) + let dayChange: Decimal = 0 + let dayChangePercentage: Double = 0 + + // Get sparkline data (last 6 data points) + let sparklineData: [Decimal] = [] + + return InvestmentWidgetEntry( + date: Date(), + totalValue: totalValue, + dayChange: dayChange, + dayChangePercentage: dayChangePercentage, + topSources: Array(topSources), + sparklineData: sparklineData + ) + } +} + +// MARK: - Small Widget View + +struct SmallWidgetView: View { + let entry: InvestmentWidgetEntry + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Portfolio") + .font(.caption) + .foregroundColor(.secondary) + + Text(entry.totalValue.compactCurrencyString) + .font(.title2.weight(.bold)) + .minimumScaleFactor(0.7) + .lineLimit(1) + + Spacer() + + HStack(spacing: 4) { + Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + + Text(String(format: "%.1f%%", entry.dayChangePercentage)) + .font(.caption.weight(.medium)) + } + .foregroundColor(entry.dayChange >= 0 ? .green : .red) + } + .padding() + .containerBackground(.background, for: .widget) + } +} + +// MARK: - Medium Widget View + +struct MediumWidgetView: View { + let entry: InvestmentWidgetEntry + + var body: some View { + HStack(spacing: 16) { + // Left side - Total value + VStack(alignment: .leading, spacing: 8) { + Text("Portfolio") + .font(.caption) + .foregroundColor(.secondary) + + Text(entry.totalValue.compactCurrencyString) + .font(.title.weight(.bold)) + .minimumScaleFactor(0.7) + .lineLimit(1) + + HStack(spacing: 4) { + Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + + Text(entry.dayChange.compactCurrencyString) + .font(.caption.weight(.medium)) + + Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))") + .font(.caption) + .foregroundColor(.secondary) + } + .foregroundColor(entry.dayChange >= 0 ? .green : .red) + } + + Spacer() + + // Right side - Top sources + VStack(alignment: .trailing, spacing: 6) { + ForEach(entry.topSources, id: \.name) { source in + HStack(spacing: 6) { + Text(source.name) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + + Text(source.value.shortCurrencyString) + .font(.caption.weight(.medium)) + } + } + } + } + .padding() + .containerBackground(.background, for: .widget) + } +} + +// MARK: - Accessory Circular View (Lock Screen) + +struct AccessoryCircularView: View { + let entry: InvestmentWidgetEntry + + var body: some View { + ZStack { + AccessoryWidgetBackground() + + VStack(spacing: 2) { + Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.caption) + + Text(String(format: "%.1f%%", entry.dayChangePercentage)) + .font(.caption2.weight(.semibold)) + } + } + .containerBackground(.background, for: .widget) + } +} + +// MARK: - Accessory Rectangular View (Lock Screen) + +struct AccessoryRectangularView: View { + let entry: InvestmentWidgetEntry + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Portfolio") + .font(.caption2) + .foregroundColor(.secondary) + + Text(entry.totalValue.compactCurrencyString) + .font(.headline) + + HStack(spacing: 4) { + Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + + Text(String(format: "%.1f%%", entry.dayChangePercentage)) + .font(.caption2) + } + .foregroundColor(entry.dayChange >= 0 ? .green : .red) + } + .containerBackground(.background, for: .widget) + } +} + +// MARK: - Widget Configuration + +struct InvestmentWidget: Widget { + let kind: String = "InvestmentWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: InvestmentWidgetProvider()) { entry in + InvestmentWidgetEntryView(entry: entry) + } + .configurationDisplayName("Portfolio Value") + .description("View your total investment portfolio value.") + .supportedFamilies([ + .systemSmall, + .systemMedium, + .accessoryCircular, + .accessoryRectangular + ]) + } +} + +// MARK: - Widget Entry View + +struct InvestmentWidgetEntryView: View { + @Environment(\.widgetFamily) var family + let entry: InvestmentWidgetEntry + + var body: some View { + switch family { + case .systemSmall: + SmallWidgetView(entry: entry) + case .systemMedium: + MediumWidgetView(entry: entry) + case .accessoryCircular: + AccessoryCircularView(entry: entry) + case .accessoryRectangular: + AccessoryRectangularView(entry: entry) + default: + SmallWidgetView(entry: entry) + } + } +} + +// MARK: - Widget Bundle + +@main +struct InvestmentTrackerWidgetBundle: WidgetBundle { + var body: some Widget { + InvestmentWidget() + } +} + +// MARK: - Previews + +#Preview("Small", as: .systemSmall) { + InvestmentWidget() +} timeline: { + InvestmentWidgetEntry( + date: Date(), + totalValue: 50000, + dayChange: 250, + dayChangePercentage: 0.5, + topSources: [], + sparklineData: [] + ) +} + +#Preview("Medium", as: .systemMedium) { + InvestmentWidget() +} timeline: { + InvestmentWidgetEntry( + date: Date(), + totalValue: 50000, + dayChange: 250, + dayChangePercentage: 0.5, + topSources: [ + ("Stocks", 30000, "#10B981"), + ("Bonds", 15000, "#3B82F6"), + ("Real Estate", 5000, "#F59E0B") + ], + sparklineData: [] + ) +} diff --git a/InvestmentTrackerWidgetExtension.entitlements b/InvestmentTrackerWidgetExtension.entitlements new file mode 100644 index 0000000..9d76b38 --- /dev/null +++ b/InvestmentTrackerWidgetExtension.entitlements @@ -0,0 +1,12 @@ + + + + + aps-environment + development + com.apple.developer.icloud-container-identifiers + + com.apple.security.application-groups + + +