Compare commits
No commits in common. "42ccc4f7198ccb22289908feb9df39a6ca0909c0" and "bab350dd227674777f49d758586fc1660051565c" have entirely different histories.
42ccc4f719
...
bab350dd22
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit d6affd13b01fd11e291448f5bcfa09cdbd634379
|
|
||||||
|
|
@ -6,106 +6,221 @@
|
||||||
objectVersion = 77;
|
objectVersion = 77;
|
||||||
objects = {
|
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 */
|
/* Begin PBXFileReference section */
|
||||||
0EC238CC2F0D520100E07005 /* InvestmentTracker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = InvestmentTracker.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
0E8318942F0DB2FB0030C2F9 /* Exceptions for "InvestmentTracker" folder in "InvestmentTracker" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Assets.xcassets,
|
||||||
|
Resources/Info.plist,
|
||||||
|
Resources/Localizable.strings,
|
||||||
|
);
|
||||||
|
target = 0E241E382F0DA93A00283E2F /* InvestmentTracker */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
0EC238CE2F0D520100E07005 /* InvestmentTracker */ = {
|
0E241E3B2F0DA93A00283E2F /* InvestmentTracker */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
0E8318942F0DB2FB0030C2F9 /* Exceptions for "InvestmentTracker" folder in "InvestmentTracker" target */,
|
||||||
|
);
|
||||||
path = InvestmentTracker;
|
path = InvestmentTracker;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
0E241ED22F0DAA3C00283E2F /* InvestmentTrackerWidget */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = InvestmentTrackerWidget;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
0EC238C92F0D520100E07005 /* Frameworks */ = {
|
0E241E362F0DA93A00283E2F /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
0E241EC92F0DAA3C00283E2F /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
0E241ED12F0DAA3C00283E2F /* SwiftUI.framework in Frameworks */,
|
||||||
|
0E241ECF2F0DAA3C00283E2F /* WidgetKit.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
0EC238C32F0D520100E07005 = {
|
0E241E302F0DA93A00283E2F = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0EC238CE2F0D520100E07005 /* InvestmentTracker */,
|
0E241EED2F0DAC7D00283E2F /* InvestmentTrackerWidgetExtension.entitlements */,
|
||||||
0EC238CD2F0D520100E07005 /* Products */,
|
0E241E3B2F0DA93A00283E2F /* InvestmentTracker */,
|
||||||
|
0E241ED22F0DAA3C00283E2F /* InvestmentTrackerWidget */,
|
||||||
|
0E241ECD2F0DAA3C00283E2F /* Frameworks */,
|
||||||
|
0E241E3A2F0DA93A00283E2F /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
0EC238CD2F0D520100E07005 /* Products */ = {
|
0E241E3A2F0DA93A00283E2F /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0EC238CC2F0D520100E07005 /* InvestmentTracker.app */,
|
0E241E392F0DA93A00283E2F /* InvestmentTracker.app */,
|
||||||
|
0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
0E241ECD2F0DAA3C00283E2F /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0E241ECE2F0DAA3C00283E2F /* WidgetKit.framework */,
|
||||||
|
0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
0EC238CB2F0D520100E07005 /* InvestmentTracker */ = {
|
0E241E382F0DA93A00283E2F /* InvestmentTracker */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 0EC238DD2F0D520300E07005 /* Build configuration list for PBXNativeTarget "InvestmentTracker" */;
|
buildConfigurationList = 0E241E4A2F0DA93C00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTracker" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
0EC238C82F0D520100E07005 /* Sources */,
|
0E241E352F0DA93A00283E2F /* Sources */,
|
||||||
0EC238C92F0D520100E07005 /* Frameworks */,
|
0E241E362F0DA93A00283E2F /* Frameworks */,
|
||||||
0EC238CA2F0D520100E07005 /* Resources */,
|
0E241EE72F0DAA3E00283E2F /* Embed Foundation Extensions */,
|
||||||
|
0E8318932F0DB2FB0030C2F9 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
0E241E3B2F0DA93A00283E2F /* InvestmentTracker */,
|
||||||
|
);
|
||||||
|
name = InvestmentTracker;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = InvestmentTracker;
|
||||||
|
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 = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
0EC238CE2F0D520100E07005 /* InvestmentTracker */,
|
0E241ED22F0DAA3C00283E2F /* InvestmentTrackerWidget */,
|
||||||
);
|
);
|
||||||
name = InvestmentTracker;
|
name = InvestmentTrackerWidgetExtension;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
);
|
);
|
||||||
productName = InvestmentTracker;
|
productName = InvestmentTrackerWidgetExtension;
|
||||||
productReference = 0EC238CC2F0D520100E07005 /* InvestmentTracker.app */;
|
productReference = 0E241ECC2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension.appex */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.app-extension";
|
||||||
};
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
0EC238C42F0D520100E07005 /* Project object */ = {
|
0E241E312F0DA93A00283E2F /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 2620;
|
LastSwiftUpdateCheck = 2620;
|
||||||
LastUpgradeCheck = 2620;
|
LastUpgradeCheck = 2620;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
0EC238CB2F0D520100E07005 = {
|
0E241E382F0DA93A00283E2F = {
|
||||||
|
CreatedOnToolsVersion = 26.2;
|
||||||
|
};
|
||||||
|
0E241ECB2F0DAA3C00283E2F = {
|
||||||
CreatedOnToolsVersion = 26.2;
|
CreatedOnToolsVersion = 26.2;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 0EC238C72F0D520100E07005 /* Build configuration list for PBXProject "InvestmentTracker" */;
|
buildConfigurationList = 0E241E342F0DA93A00283E2F /* Build configuration list for PBXProject "InvestmentTracker" */;
|
||||||
developmentRegion = en;
|
developmentRegion = en;
|
||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 0EC238C32F0D520100E07005;
|
mainGroup = 0E241E302F0DA93A00283E2F;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
packageReferences = (
|
||||||
|
0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
|
||||||
|
0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */,
|
||||||
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 0EC238CD2F0D520100E07005 /* Products */;
|
productRefGroup = 0E241E3A2F0DA93A00283E2F /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
0EC238CB2F0D520100E07005 /* InvestmentTracker */,
|
0E241E382F0DA93A00283E2F /* InvestmentTracker */,
|
||||||
|
0E241ECB2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
0EC238CA2F0D520100E07005 /* Resources */ = {
|
0E241ECA2F0DAA3C00283E2F /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
0E8318932F0DB2FB0030C2F9 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
|
@ -115,7 +230,14 @@
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
0EC238C82F0D520100E07005 /* Sources */ = {
|
0E241E352F0DA93A00283E2F /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
0E241EC82F0DAA3C00283E2F /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
|
@ -124,8 +246,16 @@
|
||||||
};
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 0E241ECB2F0DAA3C00283E2F /* InvestmentTrackerWidgetExtension */;
|
||||||
|
targetProxy = 0E241EE02F0DAA3E00283E2F /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
0EC238DE2F0D520300E07005 /* Debug */ = {
|
0E241E4B2F0DA93C00283E2F /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
|
@ -133,11 +263,10 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = InvestmentTracker/InvestmentTracker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = InvestmentTracker/InvestmentTracker.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = InvestmentTracker/Info.plist;
|
INFOPLIST_FILE = InvestmentTracker/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Investment Tracker";
|
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|
@ -160,7 +289,7 @@
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
0EC238DF2F0D520300E07005 /* Release */ = {
|
0E241E4C2F0DA93C00283E2F /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
|
@ -168,11 +297,10 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = InvestmentTracker/InvestmentTracker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = InvestmentTracker/InvestmentTracker.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = InvestmentTracker/Info.plist;
|
INFOPLIST_FILE = InvestmentTracker/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Investment Tracker";
|
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|
@ -195,7 +323,7 @@
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
0EC238E02F0D520300E07005 /* Debug */ = {
|
0E241E4D2F0DA93C00283E2F /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
|
@ -258,7 +386,7 @@
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
0EC238E12F0D520300E07005 /* Release */ = {
|
0E241E4E2F0DA93C00283E2F /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
|
@ -314,28 +442,116 @@
|
||||||
};
|
};
|
||||||
name = Release;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
0EC238C72F0D520100E07005 /* Build configuration list for PBXProject "InvestmentTracker" */ = {
|
0E241E342F0DA93A00283E2F /* Build configuration list for PBXProject "InvestmentTracker" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
0EC238E02F0D520300E07005 /* Debug */,
|
0E241E4D2F0DA93C00283E2F /* Debug */,
|
||||||
0EC238E12F0D520300E07005 /* Release */,
|
0E241E4E2F0DA93C00283E2F /* Release */,
|
||||||
);
|
);
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
0EC238DD2F0D520300E07005 /* Build configuration list for PBXNativeTarget "InvestmentTracker" */ = {
|
0E241E4A2F0DA93C00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTracker" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
0EC238DE2F0D520300E07005 /* Debug */,
|
0E241E4B2F0DA93C00283E2F /* Debug */,
|
||||||
0EC238DF2F0D520300E07005 /* Release */,
|
0E241E4C2F0DA93C00283E2F /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
0E241EE32F0DAA3E00283E2F /* Build configuration list for PBXNativeTarget "InvestmentTrackerWidgetExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
0E241EE42F0DAA3E00283E2F /* Debug */,
|
||||||
|
0E241EE52F0DAA3E00283E2F /* Release */,
|
||||||
);
|
);
|
||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* 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;
|
||||||
};
|
};
|
||||||
rootObject = 0EC238C42F0D520100E07005 /* Project object */;
|
};
|
||||||
|
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 */;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict/>
|
||||||
|
</plist>
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>BuildLocationStyle</key>
|
||||||
|
<string>UseAppPreferences</string>
|
||||||
|
<key>CompilationCachingSetting</key>
|
||||||
|
<string>Default</string>
|
||||||
|
<key>CustomBuildLocationType</key>
|
||||||
|
<string>RelativeToDerivedData</string>
|
||||||
|
<key>DerivedDataLocationStyle</key>
|
||||||
|
<string>Default</string>
|
||||||
|
<key>ShowSharedSchemesAutomaticallyEnabled</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2620"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
||||||
|
BuildableName = "InvestmentTracker.app"
|
||||||
|
BlueprintName = "InvestmentTracker"
|
||||||
|
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
||||||
|
BuildableName = "InvestmentTracker.app"
|
||||||
|
BlueprintName = "InvestmentTracker"
|
||||||
|
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
||||||
|
BuildableName = "InvestmentTracker.app"
|
||||||
|
BlueprintName = "InvestmentTracker"
|
||||||
|
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2620"
|
||||||
|
wasCreatedForAppExtension = "YES"
|
||||||
|
version = "2.0">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0E241ECB2F0DAA3C00283E2F"
|
||||||
|
BuildableName = "InvestmentTrackerWidgetExtension.appex"
|
||||||
|
BlueprintName = "InvestmentTrackerWidgetExtension"
|
||||||
|
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
||||||
|
BuildableName = "InvestmentTracker.app"
|
||||||
|
BlueprintName = "InvestmentTracker"
|
||||||
|
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = ""
|
||||||
|
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||||
|
launchStyle = "0"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES"
|
||||||
|
launchAutomaticallySubstyle = "2">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
||||||
|
BuildableName = "InvestmentTracker.app"
|
||||||
|
BlueprintName = "InvestmentTracker"
|
||||||
|
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
|
launchAutomaticallySubstyle = "2">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0E241E382F0DA93A00283E2F"
|
||||||
|
BuildableName = "InvestmentTracker.app"
|
||||||
|
BlueprintName = "InvestmentTracker"
|
||||||
|
ReferencedContainer = "container:InvestmentTracker.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>InvestmentTracker.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
<key>InvestmentTrackerWidgetExtension.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
<key>Promises (Playground).xcscheme</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>2</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>SuppressBuildableAutocreation</key>
|
||||||
|
<dict>
|
||||||
|
<key>0E241E382F0DA93A00283E2F</key>
|
||||||
|
<dict>
|
||||||
|
<key>primary</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>0E241ECB2F0DAA3C00283E2F</key>
|
||||||
|
<dict>
|
||||||
|
<key>primary</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>iCloud.com.yourteam.investmenttracker</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.icloud-services</key>
|
||||||
|
<array>
|
||||||
|
<string>CloudKit</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
|
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@objc(AppSettings)
|
||||||
|
public class AppSettings: NSManagedObject, Identifiable {
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<AppSettings> {
|
||||||
|
return NSFetchRequest<AppSettings>(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> = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@objc(Category)
|
||||||
|
public class Category: NSManagedObject, Identifiable {
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<Category> {
|
||||||
|
return NSFetchRequest<Category>(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<InvestmentSource> ?? []
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@objc(InvestmentSource)
|
||||||
|
public class InvestmentSource: NSManagedObject, Identifiable {
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<InvestmentSource> {
|
||||||
|
return NSFetchRequest<InvestmentSource>(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<Snapshot> ?? []
|
||||||
|
return set.sorted { $0.date > $1.date }
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortedSnapshotsByDateAscending: [Snapshot] {
|
||||||
|
let set = snapshots as? Set<Snapshot> ?? []
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
|
||||||
|
<entity name="AppSettings" representedClassName="AppSettings" syncable="YES">
|
||||||
|
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="currency" attributeType="String" defaultValueString="EUR"/>
|
||||||
|
<attribute name="defaultNotificationTime" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="enableAnalytics" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="lastSyncDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="onboardingCompleted" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Category" representedClassName="Category" syncable="YES">
|
||||||
|
<attribute name="colorHex" attributeType="String" defaultValueString="#3B82F6"/>
|
||||||
|
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="icon" attributeType="String" defaultValueString="chart.pie.fill"/>
|
||||||
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
|
||||||
|
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
|
||||||
|
<relationship name="category" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Category" inverseName="sources" inverseEntity="Category"/>
|
||||||
|
<relationship name="snapshots" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Snapshot" inverseName="source" inverseEntity="Snapshot"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="YES">
|
||||||
|
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
|
||||||
|
<attribute name="calculatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="predictionData" optional="YES" attributeType="Binary"/>
|
||||||
|
<attribute name="sourceId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="validUntil" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="PremiumStatus" representedClassName="PremiumStatus" syncable="YES">
|
||||||
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="isFamilyShared" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="isPremium" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="lastVerificationDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="originalPurchaseDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="productIdentifier" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="purchaseDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="transactionId" optional="YES" attributeType="String"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Snapshot" representedClassName="Snapshot" syncable="YES">
|
||||||
|
<attribute name="contribution" optional="YES" attributeType="Decimal"/>
|
||||||
|
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="date" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="value" optional="YES" attributeType="Decimal"/>
|
||||||
|
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="snapshots" inverseEntity="InvestmentSource"/>
|
||||||
|
</entity>
|
||||||
|
</model>
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@objc(PredictionCache)
|
||||||
|
public class PredictionCache: NSManagedObject, Identifiable {
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<PredictionCache> {
|
||||||
|
return NSFetchRequest<PredictionCache>(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> = 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> = 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> = PredictionCache.fetchRequest()
|
||||||
|
if let caches = try? context.fetch(request) {
|
||||||
|
caches.forEach { context.delete($0) }
|
||||||
|
}
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@objc(PremiumStatus)
|
||||||
|
public class PremiumStatus: NSManagedObject, Identifiable {
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<PremiumStatus> {
|
||||||
|
return NSFetchRequest<PremiumStatus>(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> = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@objc(Snapshot)
|
||||||
|
public class Snapshot: NSManagedObject, Identifiable {
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<Snapshot> {
|
||||||
|
return NSFetchRequest<Snapshot>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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> = 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> = 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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> = 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> = 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> = 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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> = 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> = 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> = 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> = 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> = 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<String> = []
|
||||||
|
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- App Info -->
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Investment Tracker</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Supported Orientations -->
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>armv7</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- Launch Screen -->
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
|
||||||
|
<!-- Scene Configuration -->
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
|
||||||
|
<!-- AdMob App ID - REPLACE WITH YOUR REAL APP ID FOR PRODUCTION -->
|
||||||
|
<key>GADApplicationIdentifier</key>
|
||||||
|
<string>ca-app-pub-3940256099942544~1458002511</string>
|
||||||
|
|
||||||
|
<!-- AdMob Delay App Measurement -->
|
||||||
|
<key>GADDelayAppMeasurementInit</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- App Tracking Transparency -->
|
||||||
|
<key>NSUserTrackingUsageDescription</key>
|
||||||
|
<string>This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties.</string>
|
||||||
|
|
||||||
|
<!-- iCloud -->
|
||||||
|
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Background Modes -->
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- Privacy Descriptions -->
|
||||||
|
<key>NSCalendarsUsageDescription</key>
|
||||||
|
<string>Used to set investment update reminders.</string>
|
||||||
|
|
||||||
|
<!-- URL Schemes for Deep Linking -->
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>investmenttracker</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- Supports iPad Multitasking -->
|
||||||
|
<key>UISupportsDocumentBrowser</key>
|
||||||
|
<false/>
|
||||||
|
|
||||||
|
<!-- App Transport Security -->
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSAllowsArbitraryLoadsForMedia</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
|
||||||
|
<!-- Status Bar Style -->
|
||||||
|
<key>UIStatusBarStyle</key>
|
||||||
|
<string>UIStatusBarStyleDefault</string>
|
||||||
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -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...";
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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..<snapshots.count {
|
||||||
|
let previousValue = snapshots[i-1].decimalValue
|
||||||
|
let currentValue = snapshots[i].decimalValue
|
||||||
|
let contribution = snapshots[i].decimalContribution
|
||||||
|
|
||||||
|
guard previousValue > 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..<sortedMonths.count {
|
||||||
|
let previousMonth = sortedMonths[i-1]
|
||||||
|
let currentMonth = sortedMonths[i]
|
||||||
|
|
||||||
|
guard let previousSnapshots = monthlySnapshots[previousMonth],
|
||||||
|
let currentSnapshots = monthlySnapshots[currentMonth],
|
||||||
|
let previousValue = previousSnapshots.last?.decimalValue,
|
||||||
|
let currentValue = currentSnapshots.last?.decimalValue,
|
||||||
|
previousValue > 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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?)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ?? ""
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Void, Error>?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// 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<Void, Error> {
|
||||||
|
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<T>(_ result: VerificationResult<T>) 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")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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..<values.count {
|
||||||
|
smoothed = alpha * values[i] + (1 - alpha) * smoothed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate trend
|
||||||
|
var trend: Double = 0
|
||||||
|
if values.count >= 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..<splitIndex {
|
||||||
|
smoothed = 0.3 * values[i] + 0.7 * smoothed
|
||||||
|
}
|
||||||
|
|
||||||
|
let validationValues = Array(values.suffix(from: splitIndex))
|
||||||
|
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
|
||||||
|
|
||||||
|
var ssRes: Double = 0
|
||||||
|
var ssTot: Double = 0
|
||||||
|
|
||||||
|
for (i, actual) in validationValues.enumerated() {
|
||||||
|
let predicted = smoothed + (smoothed - values[splitIndex - 1]) * Double(i + 1) / Double(splitIndex)
|
||||||
|
ssRes += pow(actual - predicted, 2)
|
||||||
|
ssTot += pow(actual - meanValidation, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard ssTot != 0 else { return 0.5 }
|
||||||
|
return max(0, min(1.0, 1 - (ssRes / ssTot)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Moving Average
|
||||||
|
|
||||||
|
func predictMovingAverage(
|
||||||
|
snapshots: [Snapshot],
|
||||||
|
monthsAhead: Int = 12,
|
||||||
|
windowSize: Int = 3
|
||||||
|
) -> [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..<values.count {
|
||||||
|
changes.append(values[i] - values[i - 1])
|
||||||
|
}
|
||||||
|
let avgChange = changes.isEmpty ? 0 : changes.reduce(0, +) / Double(changes.count)
|
||||||
|
|
||||||
|
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, movingAverage + avgChange * Double(month))
|
||||||
|
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.03)
|
||||||
|
|
||||||
|
predictions.append(Prediction(
|
||||||
|
date: futureDate,
|
||||||
|
predictedValue: Decimal(predictedValue),
|
||||||
|
algorithm: .movingAverage,
|
||||||
|
confidenceInterval: Prediction.ConfidenceInterval(
|
||||||
|
lower: Decimal(max(0, predictedValue - intervalWidth)),
|
||||||
|
upper: Decimal(predictedValue + intervalWidth)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return predictions
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateMAAccuracy(snapshots: [Snapshot]) -> 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)..<splitIndex])
|
||||||
|
let movingAvg = recentWindow.reduce(0, +) / Double(windowSize)
|
||||||
|
|
||||||
|
let validationValues = Array(values.suffix(from: splitIndex))
|
||||||
|
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
|
||||||
|
|
||||||
|
var ssRes: Double = 0
|
||||||
|
var ssTot: Double = 0
|
||||||
|
|
||||||
|
for actual in validationValues {
|
||||||
|
ssRes += pow(actual - movingAvg, 2)
|
||||||
|
ssTot += pow(actual - meanValidation, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard ssTot != 0 else { return 0.5 }
|
||||||
|
return max(0, min(1.0, 1 - (ssRes / ssTot)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func calculateVolatility(snapshots: [Snapshot]) -> Double {
|
||||||
|
let values = snapshots.map { $0.decimalValue.doubleValue }
|
||||||
|
guard values.count >= 2 else { return 0 }
|
||||||
|
|
||||||
|
var returns: [Double] = []
|
||||||
|
for i in 1..<values.count {
|
||||||
|
guard values[i - 1] != 0 else { continue }
|
||||||
|
let periodReturn = (values[i] - values[i - 1]) / values[i - 1] * 100
|
||||||
|
returns.append(periodReturn)
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculateStdDev(values: returns)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateStdDev(values: [Double]) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
// 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..<sortedSnapshots.count {
|
||||||
|
let window = Array(sortedSnapshots[(i - windowSize)..<i])
|
||||||
|
let values = window.map { NSDecimalNumber(decimal: $0.decimalValue).doubleValue }
|
||||||
|
|
||||||
|
let mean = values.reduce(0, +) / Double(values.count)
|
||||||
|
let variance = values.map { pow($0 - mean, 2) }.reduce(0, +) / Double(values.count)
|
||||||
|
let stdDev = sqrt(variance)
|
||||||
|
let volatility = mean > 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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..<pages.count, id: \.self) { index in
|
||||||
|
OnboardingPageView(page: pages[index])
|
||||||
|
.tag(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
.animation(.easeInOut, value: currentPage)
|
||||||
|
|
||||||
|
// Page indicators
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(0..<pages.count, id: \.self) { index in
|
||||||
|
Circle()
|
||||||
|
.fill(currentPage == index ? Color.appPrimary : Color.gray.opacity(0.3))
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
.animation(.easeInOut, value: currentPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
if currentPage < pages.count - 1 {
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
currentPage += 1
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Continue")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.appPrimary)
|
||||||
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
completeOnboarding()
|
||||||
|
} label: {
|
||||||
|
Text("Skip")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
completeOnboarding()
|
||||||
|
} label: {
|
||||||
|
Text("Get Started")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.appPrimary)
|
||||||
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.bottom, 40)
|
||||||
|
}
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completeOnboarding() {
|
||||||
|
// Create default categories
|
||||||
|
let categoryRepository = CategoryRepository()
|
||||||
|
categoryRepository.createDefaultCategoriesIfNeeded()
|
||||||
|
|
||||||
|
// Mark onboarding as complete
|
||||||
|
let context = CoreDataStack.shared.viewContext
|
||||||
|
AppSettings.markOnboardingComplete(in: context)
|
||||||
|
|
||||||
|
// Log analytics
|
||||||
|
FirebaseService.shared.logOnboardingCompleted(stepCount: pages.count)
|
||||||
|
|
||||||
|
// Request notification permission
|
||||||
|
Task {
|
||||||
|
await NotificationService.shared.requestAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
withAnimation {
|
||||||
|
onboardingCompleted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Onboarding Page Model
|
||||||
|
|
||||||
|
struct OnboardingPage {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let description: String
|
||||||
|
let color: Color
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Onboarding Page View
|
||||||
|
|
||||||
|
struct OnboardingPageView: View {
|
||||||
|
let page: OnboardingPage
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 32) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Icon
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(page.color.opacity(0.15))
|
||||||
|
.frame(width: 140, height: 140)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(page.color.opacity(0.3))
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
|
||||||
|
Image(systemName: page.icon)
|
||||||
|
.font(.system(size: 50))
|
||||||
|
.foregroundColor(page.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text(page.title)
|
||||||
|
.font(.title.weight(.bold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text(page.description)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - First Launch Welcome
|
||||||
|
|
||||||
|
struct WelcomeView: View {
|
||||||
|
@Binding var showWelcome: Bool
|
||||||
|
let userName: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "hand.wave.fill")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundColor(.appPrimary)
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if let name = userName {
|
||||||
|
Text("Welcome back, \(name)!")
|
||||||
|
.font(.title.weight(.bold))
|
||||||
|
} else {
|
||||||
|
Text("Welcome!")
|
||||||
|
.font(.title.weight(.bold))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Your investment data has been synced from iCloud.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
showWelcome = false
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Continue")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.appPrimary)
|
||||||
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.bottom, 40)
|
||||||
|
}
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
OnboardingView(onboardingCompleted: .constant(false))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,366 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PaywallView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@EnvironmentObject var iapService: IAPService
|
||||||
|
|
||||||
|
@State private var isPurchasing = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@State private var showingError = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
// Header
|
||||||
|
headerSection
|
||||||
|
|
||||||
|
// Features List
|
||||||
|
featuresSection
|
||||||
|
|
||||||
|
// Price Card
|
||||||
|
priceCard
|
||||||
|
|
||||||
|
// Purchase Button
|
||||||
|
purchaseButton
|
||||||
|
|
||||||
|
// Restore Button
|
||||||
|
restoreButton
|
||||||
|
|
||||||
|
// Legal
|
||||||
|
legalSection
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("Upgrade to Premium")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button {
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: $showingError) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(errorMessage ?? "An error occurred")
|
||||||
|
}
|
||||||
|
.onChange(of: iapService.isPremium) { _, isPremium in
|
||||||
|
if isPremium {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header Section
|
||||||
|
|
||||||
|
private var headerSection: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Crown icon
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.yellow.opacity(0.3), Color.orange.opacity(0.3)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
|
Image(systemName: "crown.fill")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.yellow, .orange],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Unlock Full Potential")
|
||||||
|
.font(.title.weight(.bold))
|
||||||
|
|
||||||
|
Text("Get unlimited access to all features with a one-time purchase")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Features Section
|
||||||
|
|
||||||
|
private var featuresSection: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(IAPService.premiumFeatures, id: \.title) { feature in
|
||||||
|
FeatureRow(
|
||||||
|
icon: feature.icon,
|
||||||
|
title: feature.title,
|
||||||
|
description: feature.description
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Price Card
|
||||||
|
|
||||||
|
private var priceCard: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack(alignment: .top, spacing: 4) {
|
||||||
|
Text("€")
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
.foregroundColor(.appPrimary)
|
||||||
|
|
||||||
|
Text("4.69")
|
||||||
|
.font(.system(size: 48, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("One-time purchase")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "person.2.fill")
|
||||||
|
.font(.caption)
|
||||||
|
Text("Includes Family Sharing")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundColor(.appSecondary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 24)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius)
|
||||||
|
.stroke(Color.appPrimary, lineWidth: 2)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Purchase Button
|
||||||
|
|
||||||
|
private var purchaseButton: some View {
|
||||||
|
Button {
|
||||||
|
purchase()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
if isPurchasing {
|
||||||
|
ProgressView()
|
||||||
|
.tint(.white)
|
||||||
|
} else {
|
||||||
|
Text("Upgrade Now")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.appPrimary)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||||
|
}
|
||||||
|
.disabled(isPurchasing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Restore Button
|
||||||
|
|
||||||
|
private var restoreButton: some View {
|
||||||
|
Button {
|
||||||
|
restore()
|
||||||
|
} label: {
|
||||||
|
Text("Restore Purchases")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.appPrimary)
|
||||||
|
}
|
||||||
|
.disabled(isPurchasing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Legal Section
|
||||||
|
|
||||||
|
private var legalSection: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("Payment will be charged to your Apple ID account. By purchasing, you agree to our Terms of Service and Privacy Policy.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Link("Terms", destination: URL(string: AppConstants.URLs.termsOfService)!)
|
||||||
|
.font(.caption)
|
||||||
|
|
||||||
|
Link("Privacy", destination: URL(string: AppConstants.URLs.privacyPolicy)!)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func purchase() {
|
||||||
|
isPurchasing = true
|
||||||
|
FirebaseService.shared.logPurchaseAttempt(productId: IAPService.premiumProductID)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await iapService.purchase()
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
showingError = true
|
||||||
|
}
|
||||||
|
isPurchasing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restore() {
|
||||||
|
isPurchasing = true
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await iapService.restorePurchases()
|
||||||
|
|
||||||
|
if !iapService.isPremium {
|
||||||
|
errorMessage = "No purchases found to restore"
|
||||||
|
showingError = true
|
||||||
|
}
|
||||||
|
isPurchasing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Feature Row
|
||||||
|
|
||||||
|
struct FeatureRow: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let description: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundColor(.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
|
||||||
|
Text(description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.positiveGreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compact Paywall (for inline use)
|
||||||
|
|
||||||
|
struct CompactPaywallBanner: View {
|
||||||
|
@Binding var showingPaywall: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "crown.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.yellow, .orange],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Unlock Premium")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
|
||||||
|
Text("Get unlimited access to all features")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showingPaywall = true
|
||||||
|
} label: {
|
||||||
|
Text("€4.69")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.appPrimary)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Premium Lock Overlay
|
||||||
|
|
||||||
|
struct PremiumLockOverlay: View {
|
||||||
|
let feature: String
|
||||||
|
@Binding var showingPaywall: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundColor(.appWarning)
|
||||||
|
|
||||||
|
Text("Premium Feature")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text(feature)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showingPaywall = true
|
||||||
|
} label: {
|
||||||
|
Label("Unlock", systemImage: "crown.fill")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.appPrimary)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(.systemBackground).opacity(0.95))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
PaywallView()
|
||||||
|
.environmentObject(IAPService())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,388 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@EnvironmentObject var iapService: IAPService
|
||||||
|
@StateObject private var viewModel: SettingsViewModel
|
||||||
|
|
||||||
|
init() {
|
||||||
|
_viewModel = StateObject(wrappedValue: SettingsViewModel(iapService: IAPService()))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
// Premium Section
|
||||||
|
premiumSection
|
||||||
|
|
||||||
|
// Notifications Section
|
||||||
|
notificationsSection
|
||||||
|
|
||||||
|
// Data Section
|
||||||
|
dataSection
|
||||||
|
|
||||||
|
// About Section
|
||||||
|
aboutSection
|
||||||
|
|
||||||
|
// Danger Zone
|
||||||
|
dangerZoneSection
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
.sheet(isPresented: $viewModel.showingPaywall) {
|
||||||
|
PaywallView()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $viewModel.showingExportOptions) {
|
||||||
|
ExportOptionsSheet(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Reset All Data",
|
||||||
|
isPresented: $viewModel.showingResetConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Reset Everything", role: .destructive) {
|
||||||
|
viewModel.resetAllData()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("This will permanently delete all your investment data. This action cannot be undone.")
|
||||||
|
}
|
||||||
|
.alert("Success", isPresented: .constant(viewModel.successMessage != nil)) {
|
||||||
|
Button("OK") {
|
||||||
|
viewModel.successMessage = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(viewModel.successMessage ?? "")
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
|
||||||
|
Button("OK") {
|
||||||
|
viewModel.errorMessage = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(viewModel.errorMessage ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Premium Section
|
||||||
|
|
||||||
|
private var premiumSection: some View {
|
||||||
|
Section {
|
||||||
|
if viewModel.isPremium {
|
||||||
|
HStack {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.yellow.opacity(0.2))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
Image(systemName: "crown.fill")
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.yellow, .orange],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Premium Active")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if viewModel.isFamilyShared {
|
||||||
|
Text("Family Sharing")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "checkmark.seal.fill")
|
||||||
|
.foregroundColor(.positiveGreen)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
viewModel.upgradeToPremium()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.appPrimary.opacity(0.1))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
Image(systemName: "crown.fill")
|
||||||
|
.foregroundColor(.appPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Upgrade to Premium")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Text("Unlock all features for €4.69")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await viewModel.restorePurchases()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Restore Purchases")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Subscription")
|
||||||
|
} footer: {
|
||||||
|
if !viewModel.isPremium {
|
||||||
|
Text("Free: \(viewModel.sourceLimitText) • \(viewModel.historyLimitText)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notifications Section
|
||||||
|
|
||||||
|
private var notificationsSection: some View {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Text("Notifications")
|
||||||
|
Spacer()
|
||||||
|
Text(viewModel.notificationsEnabled ? "Enabled" : "Disabled")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
if !viewModel.notificationsEnabled {
|
||||||
|
Task {
|
||||||
|
await viewModel.requestNotificationPermission()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
viewModel.openSystemSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.notificationsEnabled {
|
||||||
|
DatePicker(
|
||||||
|
"Default Reminder Time",
|
||||||
|
selection: $viewModel.defaultNotificationTime,
|
||||||
|
displayedComponents: .hourAndMinute
|
||||||
|
)
|
||||||
|
.onChange(of: viewModel.defaultNotificationTime) { _, newTime in
|
||||||
|
viewModel.updateNotificationTime(newTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Notifications")
|
||||||
|
} footer: {
|
||||||
|
Text("Set when you'd like to receive investment update reminders.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Section
|
||||||
|
|
||||||
|
private var dataSection: some View {
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
if viewModel.canExport {
|
||||||
|
viewModel.showingExportOptions = true
|
||||||
|
} else {
|
||||||
|
viewModel.showingPaywall = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Label("Export Data", systemImage: "square.and.arrow.up")
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if !viewModel.canExport {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.appWarning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Total Sources")
|
||||||
|
Spacer()
|
||||||
|
Text("\(viewModel.totalSources)")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Total Snapshots")
|
||||||
|
Spacer()
|
||||||
|
Text("\(viewModel.totalSnapshots)")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Storage Used")
|
||||||
|
Spacer()
|
||||||
|
Text(viewModel.storageUsedText)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - About Section
|
||||||
|
|
||||||
|
private var aboutSection: some View {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Text("Version")
|
||||||
|
Spacer()
|
||||||
|
Text(viewModel.appVersion)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Link(destination: URL(string: AppConstants.URLs.privacyPolicy)!) {
|
||||||
|
HStack {
|
||||||
|
Text("Privacy Policy")
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Link(destination: URL(string: AppConstants.URLs.termsOfService)!) {
|
||||||
|
HStack {
|
||||||
|
Text("Terms of Service")
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Link(destination: URL(string: AppConstants.URLs.support)!) {
|
||||||
|
HStack {
|
||||||
|
Text("Support")
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
requestAppReview()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Rate App")
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.foregroundColor(.yellow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("About")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Danger Zone Section
|
||||||
|
|
||||||
|
private var dangerZoneSection: some View {
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
viewModel.showingResetConfirmation = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
Text("Reset All Data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Danger Zone")
|
||||||
|
} footer: {
|
||||||
|
Text("This will permanently delete all your investment sources, snapshots, and settings.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func requestAppReview() {
|
||||||
|
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
|
||||||
|
import StoreKit
|
||||||
|
SKStoreReviewController.requestReview(in: scene)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Export Options Sheet
|
||||||
|
|
||||||
|
struct ExportOptionsSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@ObservedObject var viewModel: SettingsViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
viewModel.exportData(format: .csv)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "tablecells")
|
||||||
|
.foregroundColor(.positiveGreen)
|
||||||
|
.frame(width: 30)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("CSV")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Compatible with Excel, Google Sheets")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.exportData(format: .json)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "doc.text")
|
||||||
|
.foregroundColor(.appPrimary)
|
||||||
|
.frame(width: 30)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("JSON")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Full data structure for backup")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Select Format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Export Data")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SettingsView()
|
||||||
|
.environmentObject(IAPService())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,476 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddSourceView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@EnvironmentObject var iapService: IAPService
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var selectedCategory: Category?
|
||||||
|
@State private var notificationFrequency: NotificationFrequency = .monthly
|
||||||
|
@State private var customFrequencyMonths = 1
|
||||||
|
@State private var initialValue = ""
|
||||||
|
@State private var showingCategoryPicker = false
|
||||||
|
|
||||||
|
@StateObject private var categoryRepository = CategoryRepository()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// Source Info
|
||||||
|
Section {
|
||||||
|
TextField("Source Name", text: $name)
|
||||||
|
.textContentType(.organizationName)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Select")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Source Information")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial Value (Optional)
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Text("€")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
TextField("0.00", text: $initialValue)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Initial Value (Optional)")
|
||||||
|
} footer: {
|
||||||
|
Text("You can add snapshots later if you prefer.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification Settings
|
||||||
|
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")
|
||||||
|
} 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())
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<InvestmentWidgetEntry>) -> 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> = 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: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
|
<array/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
Loading…
Reference in New Issue