Add debug bypass subscription toggle, tests, and data layer improvements
- Add runtime toggle in Settings (DEBUG only) to bypass subscription/hide trial banner - IAPManager.bypassSubscription is now a @Published var persisted via UserDefaults - Hide upgrade banner in SettingsTabView and trial warnings when bypass is enabled - Add FeelsTests directory with integration tests - Update DataController, DataControllerGET, DataControllerUPDATE - Update Xcode project and scheme configuration - Update localization strings and App Store screen docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,9 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1C0DAB51279DB0FB003B1F21 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; };
|
||||
1C0DAB52279DB0FB003B1F22 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; };
|
||||
06E4767B5977FAC8B644FC92 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CFAE86F485C853DB3239DD9 /* IntegrationTests.swift */; };
|
||||
1C0DAB51279DB0FB003B1F21 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */; };
|
||||
1C0DAB52279DB0FB003B1F22 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */; };
|
||||
1C9566442EF8F5F70032E68F /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 1C9566432EF8F5F70032E68F /* Algorithms */; };
|
||||
1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB4D09F28787D8A00902A56 /* StoreKit.framework */; };
|
||||
1CD90B07278C7DE0001C4FEA /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B06278C7DE0001C4FEA /* Tests_iOS.swift */; };
|
||||
@@ -24,6 +25,10 @@
|
||||
1CDEFBBF2F3B8736006AE6A1 /* Configuration.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */; };
|
||||
1CDEFBC02F3B8736006AE6A1 /* Configuration.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */; };
|
||||
46F07FA9D330456697C9AC29 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B47278C7E7A001C4FEA /* WidgetKit.framework */; };
|
||||
4F1C717B7747918A459322CB /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4D304CD05CC7C662CCD7DCB /* Foundation.framework */; };
|
||||
54259F7B3F4E959B3F4055E4 /* StreakTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29E2A2FC314F88244CA946BF /* StreakTests.swift */; };
|
||||
9559409B5AEEAB40EBCB6AF9 /* VoteLogicsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD717F91BD65382B7DDFE3C4 /* VoteLogicsTests.swift */; };
|
||||
EEB21B1CAA8EAEB497BD9FB3 /* DataControllerCRUDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5566271983AEDF1D33C34FE6 /* DataControllerCRUDTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -48,6 +53,13 @@
|
||||
remoteGlobalIDString = 1CD90B44278C7E7A001C4FEA;
|
||||
remoteInfo = FeelsWidgetExtension;
|
||||
};
|
||||
A0B973C7674930232515563A /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 1CD90AE6278C7DDF001C4FEA /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 1CD90AF4278C7DE0001C4FEA;
|
||||
remoteInfo = "Feels (iOS)";
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -65,7 +77,7 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Feels/Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Feels/Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
1CB4D09F28787D8A00902A56 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.5.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; };
|
||||
1CD90AF5278C7DE0001C4FEA /* Feels.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Feels.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
1CD90AFB278C7DE0001C4FEA /* Feels.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Feels.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -87,12 +99,18 @@
|
||||
1CD90B70278C8000001C4FEA /* Feels (iOS)Dev.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = "Feels (iOS)Dev.entitlements"; sourceTree = "<group>"; };
|
||||
1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Configuration.storekit; sourceTree = "<group>"; };
|
||||
1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Feels Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B60015D02A064FF582E232FD /* Feels Watch App/Feels Watch AppDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch AppDebug.entitlements"; sourceTree = "<group>"; };
|
||||
B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App/Feels Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch App.entitlements"; sourceTree = "<group>"; };
|
||||
29E2A2FC314F88244CA946BF /* StreakTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StreakTests.swift; sourceTree = "<group>"; };
|
||||
5566271983AEDF1D33C34FE6 /* DataControllerCRUDTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DataControllerCRUDTests.swift; sourceTree = "<group>"; };
|
||||
9CFAE86F485C853DB3239DD9 /* IntegrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = "<group>"; };
|
||||
B60015D02A064FF582E232FD /* Feels Watch AppDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch AppDebug.entitlements"; sourceTree = "<group>"; };
|
||||
B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch App.entitlements"; sourceTree = "<group>"; };
|
||||
DA0D74ACDD741CFA1F14F50F /* FeelsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FeelsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
DD717F91BD65382B7DDFE3C4 /* VoteLogicsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VoteLogicsTests.swift; sourceTree = "<group>"; };
|
||||
F4D304CD05CC7C662CCD7DCB /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
1C000C162EE93AE3009C9ED5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||
1C000C162EE93AE3009C9ED5 /* Exceptions for "Shared" folder in "FeelsWidgetExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
"Color+Codable.swift",
|
||||
@@ -122,7 +140,7 @@
|
||||
);
|
||||
target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */;
|
||||
};
|
||||
2166CE8AA7264FC2B4BFAAAC /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||
2166CE8AA7264FC2B4BFAAAC /* Exceptions for "Shared" folder in "Feels Watch App" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Models/Mood.swift,
|
||||
@@ -137,12 +155,52 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
1C00073D2EE9388A009C9ED5 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2166CE8AA7264FC2B4BFAAAC /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 1C000C162EE93AE3009C9ED5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = "<group>"; };
|
||||
1C0009922EE938FC009C9ED5 /* FeelsWidget2 */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = FeelsWidget2; sourceTree = "<group>"; };
|
||||
579031D619ED4B989145EEB1 /* Feels Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Feels Watch App"; sourceTree = "<group>"; };
|
||||
1C00073D2EE9388A009C9ED5 /* Shared */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
2166CE8AA7264FC2B4BFAAAC /* Exceptions for "Shared" folder in "Feels Watch App" target */,
|
||||
1C000C162EE93AE3009C9ED5 /* Exceptions for "Shared" folder in "FeelsWidgetExtension" target */,
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1C0009922EE938FC009C9ED5 /* FeelsWidget2 */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = FeelsWidget2;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
579031D619ED4B989145EEB1 /* Feels Watch App */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = "Feels Watch App";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
0DC68E3188164EBC373A6BF3 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4F1C717B7747918A459322CB /* Foundation.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1CD90AF2278C7DE0001C4FEA /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -199,9 +257,9 @@
|
||||
1CD90AE5278C7DDF001C4FEA = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App/Feels Watch App.entitlements */,
|
||||
B60015D02A064FF582E232FD /* Feels Watch App/Feels Watch AppDebug.entitlements */,
|
||||
1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */,
|
||||
B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App.entitlements */,
|
||||
B60015D02A064FF582E232FD /* Feels Watch AppDebug.entitlements */,
|
||||
1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */,
|
||||
1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */,
|
||||
1CD90B6A278C7F75001C4FEA /* Feels (iOS).entitlements */,
|
||||
1CD90B70278C8000001C4FEA /* Feels (iOS)Dev.entitlements */,
|
||||
@@ -216,6 +274,7 @@
|
||||
1CD90B11278C7DE0001C4FEA /* Tests macOS */,
|
||||
1CD90B46278C7E7A001C4FEA /* Frameworks */,
|
||||
1CD90AF6278C7DE0001C4FEA /* Products */,
|
||||
38D005587E22737DC6291955 /* FeelsTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -228,6 +287,7 @@
|
||||
1CD90B02278C7DE0001C4FEA /* Tests iOS.xctest */,
|
||||
1CD90B0E278C7DE0001C4FEA /* Tests macOS.xctest */,
|
||||
1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */,
|
||||
DA0D74ACDD741CFA1F14F50F /* FeelsTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -265,10 +325,31 @@
|
||||
1CD90B6B278C7F78001C4FEA /* CloudKit.framework */,
|
||||
1CD90B47278C7E7A001C4FEA /* WidgetKit.framework */,
|
||||
1CD90B49278C7E7A001C4FEA /* SwiftUI.framework */,
|
||||
88F4C25CA0D11FB136B0B8A6 /* iOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
38D005587E22737DC6291955 /* FeelsTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5566271983AEDF1D33C34FE6 /* DataControllerCRUDTests.swift */,
|
||||
9CFAE86F485C853DB3239DD9 /* IntegrationTests.swift */,
|
||||
29E2A2FC314F88244CA946BF /* StreakTests.swift */,
|
||||
DD717F91BD65382B7DDFE3C4 /* VoteLogicsTests.swift */,
|
||||
);
|
||||
name = FeelsTests;
|
||||
path = FeelsTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
88F4C25CA0D11FB136B0B8A6 /* iOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F4D304CD05CC7C662CCD7DCB /* Foundation.framework */,
|
||||
);
|
||||
name = iOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -367,8 +448,6 @@
|
||||
1C0009922EE938FC009C9ED5 /* FeelsWidget2 */,
|
||||
);
|
||||
name = FeelsWidgetExtension;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = FeelsWidgetExtension;
|
||||
productReference = 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
@@ -389,12 +468,28 @@
|
||||
579031D619ED4B989145EEB1 /* Feels Watch App */,
|
||||
);
|
||||
name = "Feels Watch App";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "Feels Watch App";
|
||||
productReference = 1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
B375A511826E3AB53E2CF51A /* FeelsTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 611E0B1E1241C11626465A8D /* Build configuration list for PBXNativeTarget "FeelsTests" */;
|
||||
buildPhases = (
|
||||
681C769809C145ECC6A2AE8B /* Sources */,
|
||||
0DC68E3188164EBC373A6BF3 /* Frameworks */,
|
||||
AE59E2C6BF9FA0FBBE07A123 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
946F2D1B29B91CD7DB732908 /* PBXTargetDependency */,
|
||||
);
|
||||
name = FeelsTests;
|
||||
productName = FeelsTests;
|
||||
productReference = DA0D74ACDD741CFA1F14F50F /* FeelsTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -425,6 +520,10 @@
|
||||
B1DB9E6543DE4A009DB00916 = {
|
||||
CreatedOnToolsVersion = 15.0;
|
||||
};
|
||||
B375A511826E3AB53E2CF51A = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
TestTargetID = 1CD90AF4278C7DE0001C4FEA;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 1CD90AE9278C7DDF001C4FEA /* Build configuration list for PBXProject "Feels" */;
|
||||
@@ -456,6 +555,7 @@
|
||||
1CD90AFA278C7DE0001C4FEA /* Feels (macOS) */,
|
||||
1CD90B01278C7DE0001C4FEA /* Tests iOS */,
|
||||
1CD90B0D278C7DE0001C4FEA /* Tests macOS */,
|
||||
B375A511826E3AB53E2CF51A /* FeelsTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -472,7 +572,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1C0DAB51279DB0FB003B1F21 /* Feels/Localizable.xcstrings in Resources */,
|
||||
1C0DAB51279DB0FB003B1F21 /* Localizable.xcstrings in Resources */,
|
||||
1CDEFBBF2F3B8736006AE6A1 /* Configuration.storekit in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -503,7 +603,14 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1CDEFBC02F3B8736006AE6A1 /* Configuration.storekit in Resources */,
|
||||
1C0DAB52279DB0FB003B1F22 /* Feels/Localizable.xcstrings in Resources */,
|
||||
1C0DAB52279DB0FB003B1F22 /* Localizable.xcstrings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AE59E2C6BF9FA0FBBE07A123 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -556,6 +663,17 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
681C769809C145ECC6A2AE8B /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
EEB21B1CAA8EAEB497BD9FB3 /* DataControllerCRUDTests.swift in Sources */,
|
||||
06E4767B5977FAC8B644FC92 /* IntegrationTests.swift in Sources */,
|
||||
54259F7B3F4E959B3F4055E4 /* StreakTests.swift in Sources */,
|
||||
9559409B5AEEAB40EBCB6AF9 /* VoteLogicsTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
@@ -574,6 +692,12 @@
|
||||
target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */;
|
||||
targetProxy = 1CD90B54278C7E7A001C4FEA /* PBXContainerItemProxy */;
|
||||
};
|
||||
946F2D1B29B91CD7DB732908 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = "Feels (iOS)";
|
||||
target = 1CD90AF4278C7DE0001C4FEA /* Feels (iOS) */;
|
||||
targetProxy = A0B973C7674930232515563A /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@@ -1020,6 +1144,24 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
298BB8B78DE12C7707210902 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = QND55P4443;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.tests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Feels.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Feels";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
67FBFEE92D1D4F8BBFBF7B1D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -1052,6 +1194,25 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
C9B28244C1A36D4F2FE7E61A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = QND55P4443;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.tests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Feels.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Feels";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -1118,6 +1279,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
611E0B1E1241C11626465A8D /* Build configuration list for PBXNativeTarget "FeelsTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
C9B28244C1A36D4F2FE7E61A /* Release */,
|
||||
298BB8B78DE12C7707210902 /* Debug */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -32,9 +32,9 @@
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1CD90B01278C7DE0001C4FEA"
|
||||
BuildableName = "Tests iOS.xctest"
|
||||
BlueprintName = "Tests iOS"
|
||||
BlueprintIdentifier = "B375A511826E3AB53E2CF51A"
|
||||
BuildableName = "FeelsTests.xctest"
|
||||
BlueprintName = "FeelsTests"
|
||||
ReferencedContainer = "container:Feels.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
|
||||
@@ -2718,6 +2718,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Bypass Subscription" : {
|
||||
"comment" : "A label describing a feature that allows users to bypass in-app purchases for testing purposes.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Cancel" : {
|
||||
"comment" : "The text for a button that dismisses the current view.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@@ -4679,6 +4683,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Data Storage Unavailable" : {
|
||||
"comment" : "An alert title when the app cannot save mood data permanently.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Date Range" : {
|
||||
"comment" : "A label describing the date range for the mood data being exported.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@@ -7791,6 +7799,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Help improve Feels by sharing anonymous usage data" : {
|
||||
"comment" : "A description of the analytics toggle.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Hide trial banner & grant full access" : {
|
||||
"comment" : "A description of the feature that hides the trial banner and grants full access to the app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"How are you feeling?" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -11003,6 +11019,10 @@
|
||||
"comment" : "A button label that pauses a live activity.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Payment Issue" : {
|
||||
"comment" : "A label indicating that there is a payment issue with a subscription.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Paywall Styles" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -14182,6 +14202,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Share Analytics" : {
|
||||
"comment" : "A label describing the purpose of the \"Share Analytics\" toggle.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"share_view_all_moods_total_template_title" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -16147,6 +16171,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Toggle anonymous usage analytics" : {
|
||||
"comment" : "A hint that describes the functionality of the \"Share Analytics\" toggle.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Top Mood" : {
|
||||
"comment" : "A label displayed above the top mood value in the month card.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@@ -18036,6 +18064,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Your mood data cannot be saved permanently. Please restart the app. If the problem persists, reinstall the app." : {
|
||||
"comment" : "An alert message displayed when the user's mood data cannot be saved permanently. It instructs the user to restart the app or reinstall it if the issue persists.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Your Personal\nDiary" : {
|
||||
"comment" : "A title describing the main feature of the premium subscription: a personal diary.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
|
||||
366
FeelsTests/DataControllerCRUDTests.swift
Normal file
366
FeelsTests/DataControllerCRUDTests.swift
Normal file
@@ -0,0 +1,366 @@
|
||||
//
|
||||
// DataControllerCRUDTests.swift
|
||||
// FeelsTests
|
||||
//
|
||||
// Tests for DataController create, read, update, and delete operations.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import SwiftData
|
||||
@testable import Feels
|
||||
|
||||
@MainActor
|
||||
final class DataControllerCreateTests: XCTestCase {
|
||||
var sut: DataController!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Phase 2: Create Tests
|
||||
|
||||
func test_add_insertsEntry() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .great, forDate: date, entryType: .listView)
|
||||
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertNotNil(entry, "Entry should exist after add")
|
||||
XCTAssertEqual(entry?.mood, .great)
|
||||
XCTAssertEqual(entry?.entryType, EntryType.listView.rawValue)
|
||||
}
|
||||
|
||||
func test_add_replacesExistingForSameDate() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .great, forDate: date, entryType: .listView)
|
||||
sut.add(mood: .bad, forDate: date, entryType: .widget)
|
||||
|
||||
let allEntries = sut.getAllEntries(byDate: date)
|
||||
XCTAssertEqual(allEntries.count, 1, "Should have exactly 1 entry after 2 adds for same date")
|
||||
XCTAssertEqual(allEntries.first?.mood, .bad, "Should keep the latest mood")
|
||||
}
|
||||
|
||||
func test_add_setsCorrectWeekday() {
|
||||
// June 15, 2024 is a Saturday (weekday = 7 in Gregorian)
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .average, forDate: date, entryType: .listView)
|
||||
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
let expectedWeekday = Calendar.current.component(.weekday, from: date)
|
||||
XCTAssertEqual(entry?.weekDay, expectedWeekday)
|
||||
}
|
||||
|
||||
func test_add_allMoodValues() {
|
||||
let moods: [Mood] = [.horrible, .bad, .average, .good, .great]
|
||||
for (i, mood) in moods.enumerated() {
|
||||
let date = makeDate(2024, 6, 10 + i)
|
||||
sut.add(mood: mood, forDate: date, entryType: .listView)
|
||||
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertEqual(entry?.mood, mood, "Mood \(mood) should persist and read back correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func test_addBatch_insertsMultiple() {
|
||||
let entries: [(mood: Mood, date: Date, entryType: EntryType)] = [
|
||||
(.great, makeDate(2024, 6, 10), .listView),
|
||||
(.good, makeDate(2024, 6, 11), .widget),
|
||||
(.average, makeDate(2024, 6, 12), .watch),
|
||||
]
|
||||
sut.addBatch(entries: entries)
|
||||
|
||||
for (mood, date, _) in entries {
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertNotNil(entry, "Entry for \(date) should exist")
|
||||
XCTAssertEqual(entry?.mood, mood)
|
||||
}
|
||||
}
|
||||
|
||||
func test_addBatch_replacesExisting() {
|
||||
let date = makeDate(2024, 6, 10)
|
||||
sut.add(mood: .horrible, forDate: date, entryType: .listView)
|
||||
|
||||
let batchEntries: [(mood: Mood, date: Date, entryType: EntryType)] = [
|
||||
(.great, date, .widget),
|
||||
]
|
||||
sut.addBatch(entries: batchEntries)
|
||||
|
||||
let allEntries = sut.getAllEntries(byDate: date)
|
||||
XCTAssertEqual(allEntries.count, 1)
|
||||
XCTAssertEqual(allEntries.first?.mood, .great)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 3: Read Tests
|
||||
|
||||
@MainActor
|
||||
final class DataControllerReadTests: XCTestCase {
|
||||
var sut: DataController!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func test_getEntry_emptyDB_returnsNil() {
|
||||
let entry = sut.getEntry(byDate: Date())
|
||||
XCTAssertNil(entry)
|
||||
}
|
||||
|
||||
func test_getEntry_boundaryExclusion() {
|
||||
// Insert entry at exactly midnight of Jan 2 (start of Jan 2)
|
||||
// Use hour=0 to place it precisely at the boundary
|
||||
let jan2Midnight = makeDate(2024, 1, 2, hour: 0)
|
||||
let entry = MoodEntryModel(forDate: jan2Midnight, mood: .great, entryType: .listView)
|
||||
sut.modelContext.insert(entry)
|
||||
sut.save()
|
||||
|
||||
// Query for Jan 1 — should NOT find the Jan 2 entry
|
||||
// getEntry builds range [Jan 1 00:00, Jan 2 00:00] — with <= the midnight entry leaks in
|
||||
let jan1 = makeDate(2024, 1, 1)
|
||||
let result = sut.getEntry(byDate: jan1)
|
||||
XCTAssertNil(result, "Entry at midnight Jan 2 should NOT be returned when querying Jan 1")
|
||||
}
|
||||
|
||||
func test_getData_filtersWeekdays() {
|
||||
// Add entries for a full week (Mon-Sun)
|
||||
// Jan 1, 2024 is a Monday (weekday=2)
|
||||
for i in 0..<7 {
|
||||
let date = Calendar.current.date(byAdding: .day, value: i, to: makeDate(2024, 1, 1))!
|
||||
sut.add(mood: .average, forDate: date, entryType: .listView)
|
||||
}
|
||||
|
||||
// Filter to only weekdays 2 (Mon) and 6 (Fri)
|
||||
let startDate = makeDate(2024, 1, 1)
|
||||
let endDate = makeDate(2024, 1, 7)
|
||||
let results = sut.getData(startDate: startDate, endDate: endDate, includedDays: [2, 6])
|
||||
|
||||
XCTAssertEqual(results.count, 2, "Should return only Monday and Friday")
|
||||
}
|
||||
|
||||
func test_getData_emptyIncludedDays_returnsAll() {
|
||||
for i in 0..<7 {
|
||||
let date = Calendar.current.date(byAdding: .day, value: i, to: makeDate(2024, 1, 1))!
|
||||
sut.add(mood: .average, forDate: date, entryType: .listView)
|
||||
}
|
||||
|
||||
let startDate = makeDate(2024, 1, 1)
|
||||
let endDate = makeDate(2024, 1, 7)
|
||||
let results = sut.getData(startDate: startDate, endDate: endDate, includedDays: [])
|
||||
|
||||
XCTAssertEqual(results.count, 7, "Empty includedDays should return all 7 entries")
|
||||
}
|
||||
|
||||
func test_splitIntoYearMonth_groupsCorrectly() {
|
||||
// Add entries in different months
|
||||
sut.add(mood: .great, forDate: makeDate(2024, 1, 15), entryType: .listView)
|
||||
sut.add(mood: .good, forDate: makeDate(2024, 1, 20), entryType: .listView)
|
||||
sut.add(mood: .bad, forDate: makeDate(2024, 3, 5), entryType: .listView)
|
||||
|
||||
let grouped = sut.splitIntoYearMonth(includedDays: [])
|
||||
XCTAssertEqual(grouped[2024]?[1]?.count, 2, "Jan 2024 should have 2 entries")
|
||||
XCTAssertEqual(grouped[2024]?[3]?.count, 1, "Mar 2024 should have 1 entry")
|
||||
XCTAssertNil(grouped[2024]?[2], "Feb 2024 should have no entries")
|
||||
}
|
||||
|
||||
func test_earliestEntry() {
|
||||
sut.add(mood: .great, forDate: makeDate(2024, 6, 15), entryType: .listView)
|
||||
sut.add(mood: .bad, forDate: makeDate(2024, 1, 1), entryType: .listView)
|
||||
sut.add(mood: .good, forDate: makeDate(2024, 3, 10), entryType: .listView)
|
||||
|
||||
let earliest = sut.earliestEntry
|
||||
XCTAssertNotNil(earliest)
|
||||
XCTAssertTrue(Calendar.current.isDate(earliest!.forDate, inSameDayAs: makeDate(2024, 1, 1)))
|
||||
}
|
||||
|
||||
func test_latestEntry() {
|
||||
sut.add(mood: .great, forDate: makeDate(2024, 6, 15), entryType: .listView)
|
||||
sut.add(mood: .bad, forDate: makeDate(2024, 1, 1), entryType: .listView)
|
||||
sut.add(mood: .good, forDate: makeDate(2024, 3, 10), entryType: .listView)
|
||||
|
||||
let latest = sut.latestEntry
|
||||
XCTAssertNotNil(latest)
|
||||
XCTAssertTrue(Calendar.current.isDate(latest!.forDate, inSameDayAs: makeDate(2024, 6, 15)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 4: Update Tests
|
||||
|
||||
@MainActor
|
||||
final class DataControllerUpdateTests: XCTestCase {
|
||||
var sut: DataController!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func test_update_changesMood() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .bad, forDate: date, entryType: .listView)
|
||||
|
||||
let result = sut.update(entryDate: date, withMood: .great)
|
||||
XCTAssertTrue(result)
|
||||
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertEqual(entry?.mood, .great)
|
||||
}
|
||||
|
||||
func test_update_nonexistent_returnsFalse() {
|
||||
let result = sut.update(entryDate: makeDate(2024, 6, 15), withMood: .great)
|
||||
XCTAssertFalse(result)
|
||||
}
|
||||
|
||||
func test_updateNotes_setsNotes() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .good, forDate: date, entryType: .listView)
|
||||
|
||||
let result = sut.updateNotes(forDate: date, notes: "Had a great day")
|
||||
XCTAssertTrue(result)
|
||||
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertEqual(entry?.notes, "Had a great day")
|
||||
}
|
||||
|
||||
func test_updateNotes_nilClearsNotes() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .good, forDate: date, entryType: .listView)
|
||||
sut.updateNotes(forDate: date, notes: "Some notes")
|
||||
|
||||
let result = sut.updateNotes(forDate: date, notes: nil)
|
||||
XCTAssertTrue(result)
|
||||
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertNil(entry?.notes)
|
||||
}
|
||||
|
||||
func test_updatePhoto_setsPhotoID() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .good, forDate: date, entryType: .listView)
|
||||
|
||||
let photoID = UUID()
|
||||
let result = sut.updatePhoto(forDate: date, photoID: photoID)
|
||||
XCTAssertTrue(result)
|
||||
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertEqual(entry?.photoID, photoID)
|
||||
}
|
||||
|
||||
func test_update_refreshesTimestamp() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
sut.add(mood: .bad, forDate: date, entryType: .listView)
|
||||
|
||||
let originalTimestamp = sut.getEntry(byDate: date)!.timestamp
|
||||
|
||||
// Small delay so timestamp would differ
|
||||
let result = sut.update(entryDate: date, withMood: .great)
|
||||
XCTAssertTrue(result)
|
||||
|
||||
let updatedEntry = sut.getEntry(byDate: date)!
|
||||
XCTAssertGreaterThanOrEqual(updatedEntry.timestamp, originalTimestamp,
|
||||
"Timestamp should be updated on mood change so removeDuplicates picks the right entry")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 5: Delete Tests
|
||||
|
||||
@MainActor
|
||||
final class DataControllerDeleteTests: XCTestCase {
|
||||
var sut: DataController!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func test_clearDB_removesAll() {
|
||||
sut.add(mood: .great, forDate: makeDate(2024, 6, 10), entryType: .listView)
|
||||
sut.add(mood: .bad, forDate: makeDate(2024, 6, 11), entryType: .listView)
|
||||
sut.add(mood: .good, forDate: makeDate(2024, 6, 12), entryType: .listView)
|
||||
|
||||
sut.clearDB()
|
||||
|
||||
let all = sut.getData(startDate: Date(timeIntervalSince1970: 0), endDate: Date(), includedDays: [])
|
||||
XCTAssertTrue(all.isEmpty, "clearDB should remove all entries")
|
||||
}
|
||||
|
||||
func test_deleteAllEntries_removesOnlyTargetDate() {
|
||||
let date1 = makeDate(2024, 6, 10)
|
||||
let date2 = makeDate(2024, 6, 11)
|
||||
sut.add(mood: .great, forDate: date1, entryType: .listView)
|
||||
sut.add(mood: .bad, forDate: date2, entryType: .listView)
|
||||
|
||||
sut.deleteAllEntries(forDate: date1)
|
||||
|
||||
XCTAssertNil(sut.getEntry(byDate: date1), "Deleted date should be gone")
|
||||
XCTAssertNotNil(sut.getEntry(byDate: date2), "Other dates should be untouched")
|
||||
}
|
||||
|
||||
func test_removeDuplicates_keepsBestEntry() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
|
||||
// Manually insert 3 entries for the same date to simulate CloudKit conflict
|
||||
let entry1 = MoodEntryModel(forDate: date, mood: .missing, entryType: .filledInMissing)
|
||||
entry1.timestamp = makeDate(2024, 6, 15, hour: 8)
|
||||
sut.modelContext.insert(entry1)
|
||||
|
||||
let entry2 = MoodEntryModel(forDate: date, mood: .great, entryType: .listView)
|
||||
entry2.timestamp = makeDate(2024, 6, 15, hour: 10)
|
||||
sut.modelContext.insert(entry2)
|
||||
|
||||
let entry3 = MoodEntryModel(forDate: date, mood: .good, entryType: .listView)
|
||||
entry3.timestamp = makeDate(2024, 6, 15, hour: 9)
|
||||
sut.modelContext.insert(entry3)
|
||||
|
||||
sut.save()
|
||||
|
||||
let removed = sut.removeDuplicates()
|
||||
XCTAssertEqual(removed, 2, "Should remove 2 duplicates")
|
||||
|
||||
let remaining = sut.getAllEntries(byDate: date)
|
||||
XCTAssertEqual(remaining.count, 1, "Should keep exactly 1 entry")
|
||||
// Should keep non-missing with most recent timestamp (entry2, .great at 10am)
|
||||
XCTAssertEqual(remaining.first?.mood, .great, "Should keep the best entry (non-missing, most recent)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
func makeDate(_ year: Int, _ month: Int, _ day: Int, hour: Int = 12) -> Date {
|
||||
var components = DateComponents()
|
||||
components.year = year
|
||||
components.month = month
|
||||
components.day = day
|
||||
components.hour = hour
|
||||
components.minute = 0
|
||||
components.second = 0
|
||||
return Calendar.current.date(from: components)!
|
||||
}
|
||||
141
FeelsTests/IntegrationTests.swift
Normal file
141
FeelsTests/IntegrationTests.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
//
|
||||
// IntegrationTests.swift
|
||||
// FeelsTests
|
||||
//
|
||||
// Integration tests verifying full lifecycle pipelines.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import SwiftData
|
||||
@testable import Feels
|
||||
|
||||
@MainActor
|
||||
final class IntegrationTests: XCTestCase {
|
||||
var sut: DataController!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Phase 8: Integration Pipelines
|
||||
|
||||
func test_fullCRUD_lifecycle() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
|
||||
// Create
|
||||
sut.add(mood: .good, forDate: date, entryType: .listView)
|
||||
XCTAssertNotNil(sut.getEntry(byDate: date))
|
||||
|
||||
// Read
|
||||
let entry = sut.getEntry(byDate: date)
|
||||
XCTAssertEqual(entry?.mood, .good)
|
||||
|
||||
// Update
|
||||
let updated = sut.update(entryDate: date, withMood: .great)
|
||||
XCTAssertTrue(updated)
|
||||
XCTAssertEqual(sut.getEntry(byDate: date)?.mood, .great)
|
||||
|
||||
// Update notes
|
||||
sut.updateNotes(forDate: date, notes: "Lifecycle test")
|
||||
XCTAssertEqual(sut.getEntry(byDate: date)?.notes, "Lifecycle test")
|
||||
|
||||
// Delete
|
||||
sut.deleteAllEntries(forDate: date)
|
||||
XCTAssertNil(sut.getEntry(byDate: date))
|
||||
}
|
||||
|
||||
func test_streak_throughLifecycle() {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)!
|
||||
|
||||
// Add today → streak=1
|
||||
sut.add(mood: .good, forDate: today, entryType: .listView)
|
||||
XCTAssertEqual(sut.calculateStreak(from: today).streak, 1)
|
||||
|
||||
// Add yesterday → streak=2
|
||||
sut.add(mood: .great, forDate: yesterday, entryType: .listView)
|
||||
XCTAssertEqual(sut.calculateStreak(from: today).streak, 2)
|
||||
|
||||
// Update today's mood → streak still 2
|
||||
sut.update(entryDate: today, withMood: .average)
|
||||
XCTAssertEqual(sut.calculateStreak(from: today).streak, 2)
|
||||
|
||||
// Delete today → streak=1 (counting from yesterday)
|
||||
sut.deleteAllEntries(forDate: today)
|
||||
XCTAssertEqual(sut.calculateStreak(from: today).streak, 1)
|
||||
}
|
||||
|
||||
func test_voteStatus_throughLifecycle() {
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Today
|
||||
// Set unlock time to 00:01 so we're always "after" unlock
|
||||
var components = Calendar.current.dateComponents([.year, .month, .day], from: Date())
|
||||
components.hour = 0
|
||||
components.minute = 1
|
||||
onboarding.date = Calendar.current.date(from: components)!
|
||||
|
||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding)
|
||||
|
||||
// Helper: check vote status using testable DataController
|
||||
func isVoteNeeded() -> Bool {
|
||||
let entry = sut.getEntry(byDate: votingDate.startOfDay)
|
||||
return entry == nil || entry?.mood == .missing
|
||||
}
|
||||
|
||||
// No entry → vote needed
|
||||
XCTAssertTrue(isVoteNeeded())
|
||||
|
||||
// Add entry → no vote needed
|
||||
sut.add(mood: .great, forDate: votingDate, entryType: .listView)
|
||||
XCTAssertFalse(isVoteNeeded())
|
||||
|
||||
// Delete entry → vote needed again
|
||||
sut.deleteAllEntries(forDate: votingDate)
|
||||
XCTAssertTrue(isVoteNeeded())
|
||||
}
|
||||
|
||||
func test_duplicateRemoval() {
|
||||
let date = makeDate(2024, 6, 15)
|
||||
|
||||
// Manually insert 3 entries for same date (simulating CloudKit conflict)
|
||||
let entry1 = MoodEntryModel(forDate: date, mood: .great, entryType: .listView)
|
||||
entry1.timestamp = makeDate(2024, 6, 15, hour: 10)
|
||||
sut.modelContext.insert(entry1)
|
||||
|
||||
let entry2 = MoodEntryModel(forDate: date, mood: .good, entryType: .listView)
|
||||
entry2.timestamp = makeDate(2024, 6, 15, hour: 8)
|
||||
sut.modelContext.insert(entry2)
|
||||
|
||||
let entry3 = MoodEntryModel(forDate: date, mood: .missing, entryType: .filledInMissing)
|
||||
entry3.timestamp = makeDate(2024, 6, 15, hour: 12)
|
||||
sut.modelContext.insert(entry3)
|
||||
|
||||
sut.save()
|
||||
|
||||
let removed = sut.removeDuplicates()
|
||||
XCTAssertEqual(removed, 2)
|
||||
XCTAssertEqual(sut.getAllEntries(byDate: date).count, 1)
|
||||
}
|
||||
|
||||
func test_dataListeners_fireOnMutation() {
|
||||
var listenerCallCount = 0
|
||||
sut.addNewDataListener {
|
||||
listenerCallCount += 1
|
||||
}
|
||||
|
||||
// add() calls saveAndRunDataListeners internally
|
||||
sut.add(mood: .good, forDate: makeDate(2024, 6, 15), entryType: .listView)
|
||||
|
||||
// add() does: delete-save (if existing) + insert + saveAndRunDataListeners
|
||||
// For a fresh add with no existing entry, listener fires once from saveAndRunDataListeners
|
||||
XCTAssertGreaterThanOrEqual(listenerCallCount, 1, "Listener should fire at least once on mutation")
|
||||
}
|
||||
}
|
||||
125
FeelsTests/StreakTests.swift
Normal file
125
FeelsTests/StreakTests.swift
Normal file
@@ -0,0 +1,125 @@
|
||||
//
|
||||
// StreakTests.swift
|
||||
// FeelsTests
|
||||
//
|
||||
// Tests for DataController streak calculation.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import SwiftData
|
||||
@testable import Feels
|
||||
|
||||
@MainActor
|
||||
final class StreakTests: XCTestCase {
|
||||
var sut: DataController!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
sut = DataController(container: try! ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Phase 6: Streak Calculation
|
||||
|
||||
func test_streak_emptyDB_zero() {
|
||||
let result = sut.calculateStreak(from: Date())
|
||||
XCTAssertEqual(result.streak, 0)
|
||||
XCTAssertNil(result.todaysMood)
|
||||
}
|
||||
|
||||
func test_streak_singleEntryToday_one() {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
sut.add(mood: .great, forDate: today, entryType: .listView)
|
||||
|
||||
let result = sut.calculateStreak(from: today)
|
||||
XCTAssertEqual(result.streak, 1)
|
||||
XCTAssertEqual(result.todaysMood, .great)
|
||||
}
|
||||
|
||||
func test_streak_consecutiveDays() {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
for i in 0..<5 {
|
||||
let date = Calendar.current.date(byAdding: .day, value: -i, to: today)!
|
||||
sut.add(mood: .good, forDate: date, entryType: .listView)
|
||||
}
|
||||
|
||||
let result = sut.calculateStreak(from: today)
|
||||
XCTAssertEqual(result.streak, 5)
|
||||
}
|
||||
|
||||
func test_streak_gapBreaks() {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
// Today, yesterday, then skip a day, then 2 more
|
||||
sut.add(mood: .good, forDate: today, entryType: .listView)
|
||||
sut.add(mood: .good, forDate: Calendar.current.date(byAdding: .day, value: -1, to: today)!, entryType: .listView)
|
||||
// Skip day -2
|
||||
sut.add(mood: .good, forDate: Calendar.current.date(byAdding: .day, value: -3, to: today)!, entryType: .listView)
|
||||
sut.add(mood: .good, forDate: Calendar.current.date(byAdding: .day, value: -4, to: today)!, entryType: .listView)
|
||||
|
||||
let result = sut.calculateStreak(from: today)
|
||||
XCTAssertEqual(result.streak, 2, "Streak should stop at the gap")
|
||||
}
|
||||
|
||||
func test_streak_missingDoesNotCount() {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
sut.add(mood: .good, forDate: today, entryType: .listView)
|
||||
// Yesterday is a .missing entry (should be ignored)
|
||||
sut.add(mood: .missing, forDate: Calendar.current.date(byAdding: .day, value: -1, to: today)!, entryType: .filledInMissing)
|
||||
sut.add(mood: .good, forDate: Calendar.current.date(byAdding: .day, value: -2, to: today)!, entryType: .listView)
|
||||
|
||||
let result = sut.calculateStreak(from: today)
|
||||
XCTAssertEqual(result.streak, 1, ".missing entries should not count toward streak")
|
||||
}
|
||||
|
||||
func test_streak_noEntryToday_countsFromYesterday() {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)!
|
||||
let twoDaysAgo = Calendar.current.date(byAdding: .day, value: -2, to: today)!
|
||||
|
||||
sut.add(mood: .good, forDate: yesterday, entryType: .listView)
|
||||
sut.add(mood: .great, forDate: twoDaysAgo, entryType: .listView)
|
||||
|
||||
let result = sut.calculateStreak(from: today)
|
||||
XCTAssertEqual(result.streak, 2, "Should count from yesterday when today has no entry")
|
||||
XCTAssertNil(result.todaysMood, "Today's mood should be nil when no entry exists")
|
||||
}
|
||||
|
||||
func test_streak_entryAfterVotingTime_stillCounts() {
|
||||
// Bug: calculateStreak passes votingDate (with time) as endDate to getData.
|
||||
// getData uses <=, so an entry logged AFTER votingDate's time is excluded.
|
||||
// E.g. if votingDate is "today at 8am" and user logged at 10am, the 10am entry
|
||||
// is excluded from the query, making streak miss today.
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
let morningVotingDate = Calendar.current.date(byAdding: .hour, value: 8, to: today)!
|
||||
|
||||
// Add entry at 10am (after the 8am voting date time)
|
||||
let afternoonDate = Calendar.current.date(byAdding: .hour, value: 10, to: today)!
|
||||
sut.add(mood: .great, forDate: afternoonDate, entryType: .listView)
|
||||
|
||||
let result = sut.calculateStreak(from: morningVotingDate)
|
||||
XCTAssertEqual(result.streak, 1, "Entry logged after votingDate time should still count")
|
||||
XCTAssertEqual(result.todaysMood, .great, "Today's mood should be found even if logged after votingDate time")
|
||||
}
|
||||
|
||||
func test_streak_afterDelete_decreases() {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)!
|
||||
|
||||
sut.add(mood: .good, forDate: today, entryType: .listView)
|
||||
sut.add(mood: .great, forDate: yesterday, entryType: .listView)
|
||||
|
||||
let beforeDelete = sut.calculateStreak(from: today)
|
||||
XCTAssertEqual(beforeDelete.streak, 2)
|
||||
|
||||
sut.deleteAllEntries(forDate: yesterday)
|
||||
|
||||
let afterDelete = sut.calculateStreak(from: today)
|
||||
XCTAssertEqual(afterDelete.streak, 1, "Streak should decrease after deleting a day")
|
||||
}
|
||||
}
|
||||
157
FeelsTests/VoteLogicsTests.swift
Normal file
157
FeelsTests/VoteLogicsTests.swift
Normal file
@@ -0,0 +1,157 @@
|
||||
//
|
||||
// VoteLogicsTests.swift
|
||||
// FeelsTests
|
||||
//
|
||||
// Tests for ShowBasedOnVoteLogics vote status and timing.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import SwiftData
|
||||
@testable import Feels
|
||||
|
||||
@MainActor
|
||||
final class VoteLogicsTests: XCTestCase {
|
||||
|
||||
// MARK: - Phase 7: passedTodaysVotingUnlock Tests
|
||||
|
||||
func test_passedVotingUnlock_beforeTime() {
|
||||
// Voting unlock set to 6:00 PM, current time is 2:00 PM
|
||||
let voteDate = makeDateWithTime(hour: 18, minute: 0)
|
||||
let now = makeDateWithTime(hour: 14, minute: 0)
|
||||
|
||||
let result = ShowBasedOnVoteLogics.passedTodaysVotingUnlock(voteDate: voteDate, now: now)
|
||||
XCTAssertFalse(result, "Should not have passed unlock when current time is before vote time")
|
||||
}
|
||||
|
||||
func test_passedVotingUnlock_afterTime() {
|
||||
// Voting unlock set to 6:00 PM, current time is 8:00 PM
|
||||
let voteDate = makeDateWithTime(hour: 18, minute: 0)
|
||||
let now = makeDateWithTime(hour: 20, minute: 0)
|
||||
|
||||
let result = ShowBasedOnVoteLogics.passedTodaysVotingUnlock(voteDate: voteDate, now: now)
|
||||
XCTAssertTrue(result, "Should have passed unlock when current time is after vote time")
|
||||
}
|
||||
|
||||
func test_passedVotingUnlock_exactTime() {
|
||||
// Voting unlock set to 6:00 PM, current time is exactly 6:00 PM
|
||||
let voteDate = makeDateWithTime(hour: 18, minute: 0)
|
||||
let now = makeDateWithTime(hour: 18, minute: 0)
|
||||
|
||||
let result = ShowBasedOnVoteLogics.passedTodaysVotingUnlock(voteDate: voteDate, now: now)
|
||||
XCTAssertTrue(result, "Should have passed unlock at exact vote time (uses >=)")
|
||||
}
|
||||
|
||||
// MARK: - Phase 7: getCurrentVotingDate Tests
|
||||
|
||||
func test_votingDate_today_beforeTime() {
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Today
|
||||
onboarding.date = makeDateWithTime(hour: 18, minute: 0)
|
||||
|
||||
let now = makeDateWithTime(hour: 14, minute: 0) // before 6pm
|
||||
let result = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now)
|
||||
|
||||
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)!
|
||||
XCTAssertTrue(Calendar.current.isDate(result, inSameDayAs: yesterday),
|
||||
"Today mode, before unlock: should return yesterday")
|
||||
}
|
||||
|
||||
func test_votingDate_today_afterTime() {
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Today
|
||||
onboarding.date = makeDateWithTime(hour: 18, minute: 0)
|
||||
|
||||
let now = makeDateWithTime(hour: 20, minute: 0) // after 6pm
|
||||
let result = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now)
|
||||
|
||||
XCTAssertTrue(Calendar.current.isDate(result, inSameDayAs: now),
|
||||
"Today mode, after unlock: should return today")
|
||||
}
|
||||
|
||||
func test_votingDate_previous_beforeTime() {
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Previous
|
||||
onboarding.date = makeDateWithTime(hour: 18, minute: 0)
|
||||
|
||||
let now = makeDateWithTime(hour: 14, minute: 0) // before 6pm
|
||||
let result = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now)
|
||||
|
||||
let twoDaysAgo = Calendar.current.date(byAdding: .day, value: -2, to: now)!
|
||||
XCTAssertTrue(Calendar.current.isDate(result, inSameDayAs: twoDaysAgo),
|
||||
"Previous mode, before unlock: should return 2 days ago")
|
||||
}
|
||||
|
||||
func test_votingDate_previous_afterTime() {
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Previous
|
||||
onboarding.date = makeDateWithTime(hour: 18, minute: 0)
|
||||
|
||||
let now = makeDateWithTime(hour: 20, minute: 0) // after 6pm
|
||||
let result = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding, now: now)
|
||||
|
||||
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)!
|
||||
XCTAssertTrue(Calendar.current.isDate(result, inSameDayAs: yesterday),
|
||||
"Previous mode, after unlock: should return yesterday")
|
||||
}
|
||||
|
||||
// MARK: - Phase 7: Vote Needed Tests (exercising the same logic as isMissingCurrentVote)
|
||||
// Note: isMissingCurrentVote uses DataController.shared (singleton) internally,
|
||||
// so we test the equivalent logic using a testable DataController instance.
|
||||
|
||||
func test_voteNeeded_noEntry() {
|
||||
let sut = makeTestDataController()
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Today
|
||||
onboarding.date = makeDateWithTime(hour: 0, minute: 1)
|
||||
|
||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding)
|
||||
let entry = sut.getEntry(byDate: votingDate.startOfDay)
|
||||
let isMissing = entry == nil || entry?.mood == .missing
|
||||
XCTAssertTrue(isMissing, "Should need a vote when no entry exists")
|
||||
}
|
||||
|
||||
func test_voteNeeded_validEntry() {
|
||||
let sut = makeTestDataController()
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Today
|
||||
onboarding.date = makeDateWithTime(hour: 0, minute: 1)
|
||||
|
||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding)
|
||||
sut.add(mood: .great, forDate: votingDate, entryType: .listView)
|
||||
|
||||
let entry = sut.getEntry(byDate: votingDate.startOfDay)
|
||||
let isMissing = entry == nil || entry?.mood == .missing
|
||||
XCTAssertFalse(isMissing, "Should not need a vote when valid entry exists")
|
||||
}
|
||||
|
||||
func test_voteNeeded_missingEntry() {
|
||||
let sut = makeTestDataController()
|
||||
let onboarding = OnboardingData()
|
||||
onboarding.inputDay = .Today
|
||||
onboarding.date = makeDateWithTime(hour: 0, minute: 1)
|
||||
|
||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboarding)
|
||||
sut.add(mood: .missing, forDate: votingDate, entryType: .filledInMissing)
|
||||
|
||||
let entry = sut.getEntry(byDate: votingDate.startOfDay)
|
||||
let isMissing = entry == nil || entry?.mood == .missing
|
||||
XCTAssertTrue(isMissing, "Should need a vote when entry is .missing")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeTestDataController() -> DataController {
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
return DataController(container: try! ModelContainer(for: schema, configurations: [config]))
|
||||
}
|
||||
|
||||
private func makeDateWithTime(hour: Int, minute: Int) -> Date {
|
||||
let calendar = Calendar.current
|
||||
var components = calendar.dateComponents([.year, .month, .day], from: Date())
|
||||
components.hour = hour
|
||||
components.minute = minute
|
||||
components.second = 0
|
||||
return calendar.date(from: components)!
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,13 @@ class IAPManager: ObservableObject {
|
||||
// MARK: - Debug Toggle
|
||||
|
||||
/// Set to `true` to bypass all subscription checks and grant full access (for development only)
|
||||
/// Set to `false` to test trial/subscription behavior in DEBUG builds
|
||||
/// Togglable at runtime in DEBUG builds via Settings > Debug > Bypass Subscription
|
||||
#if DEBUG
|
||||
static let bypassSubscription = false
|
||||
@Published var bypassSubscription: Bool {
|
||||
didSet { UserDefaults.standard.set(bypassSubscription, forKey: "debug_bypassSubscription") }
|
||||
}
|
||||
#else
|
||||
static let bypassSubscription = false
|
||||
let bypassSubscription = false
|
||||
#endif
|
||||
|
||||
// MARK: - Constants
|
||||
@@ -96,7 +98,7 @@ class IAPManager: ObservableObject {
|
||||
}
|
||||
|
||||
var hasFullAccess: Bool {
|
||||
if Self.bypassSubscription { return true }
|
||||
if bypassSubscription { return true }
|
||||
switch state {
|
||||
case .subscribed, .billingRetry, .gracePeriod, .inTrial:
|
||||
return true
|
||||
@@ -106,7 +108,7 @@ class IAPManager: ObservableObject {
|
||||
}
|
||||
|
||||
var shouldShowPaywall: Bool {
|
||||
if Self.bypassSubscription { return false }
|
||||
if bypassSubscription { return false }
|
||||
switch state {
|
||||
case .trialExpired, .expired, .revoked:
|
||||
return true
|
||||
@@ -116,6 +118,7 @@ class IAPManager: ObservableObject {
|
||||
}
|
||||
|
||||
var shouldShowTrialWarning: Bool {
|
||||
if bypassSubscription { return false }
|
||||
if case .inTrial = state { return true }
|
||||
return false
|
||||
}
|
||||
@@ -137,6 +140,9 @@ class IAPManager: ObservableObject {
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
#if DEBUG
|
||||
self.bypassSubscription = UserDefaults.standard.bool(forKey: "debug_bypassSubscription")
|
||||
#endif
|
||||
restoreCachedSubscriptionState()
|
||||
updateListenerTask = listenForTransactions()
|
||||
|
||||
@@ -219,7 +225,7 @@ class IAPManager: ObservableObject {
|
||||
|
||||
/// Sync subscription status to UserDefaults for widget access
|
||||
private func syncSubscriptionStatusToUserDefaults() {
|
||||
let accessValue = Self.bypassSubscription ? true : hasFullAccess
|
||||
let accessValue = bypassSubscription ? true : hasFullAccess
|
||||
GroupUserDefaults.groupDefaults.set(accessValue, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
|
||||
}
|
||||
|
||||
@@ -373,7 +379,7 @@ class IAPManager: ObservableObject {
|
||||
case .unknown:
|
||||
status = "unknown"
|
||||
isSubscribed = false
|
||||
hasFullAccess = Self.bypassSubscription
|
||||
hasFullAccess = bypassSubscription
|
||||
willAutoRenew = nil
|
||||
isInGracePeriod = nil
|
||||
trialDaysRemaining = nil
|
||||
|
||||
@@ -41,8 +41,8 @@ final class DataController: ObservableObject {
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
private init() {
|
||||
container = SharedModelContainer.createWithFallback(useCloudKit: true)
|
||||
init(container: ModelContainer? = nil) {
|
||||
self.container = container ?? SharedModelContainer.createWithFallback(useCloudKit: true)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ extension DataController {
|
||||
|
||||
var descriptor = FetchDescriptor<MoodEntryModel>(
|
||||
predicate: #Predicate { entry in
|
||||
entry.forDate >= startDate && entry.forDate <= endDate
|
||||
entry.forDate >= startDate && entry.forDate < endDate
|
||||
},
|
||||
sortBy: [SortDescriptor(\.forDate, order: .forward)]
|
||||
)
|
||||
@@ -66,7 +66,8 @@ extension DataController {
|
||||
return (0, nil)
|
||||
}
|
||||
|
||||
let entries = getData(startDate: yearAgo, endDate: votingDate, includedDays: [])
|
||||
let endOfVotingDay = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? votingDate
|
||||
let entries = getData(startDate: yearAgo, endDate: endOfVotingDay, includedDays: [])
|
||||
.filter { $0.mood != .missing && $0.mood != .placeholder }
|
||||
|
||||
guard !entries.isEmpty else { return (0, nil) }
|
||||
|
||||
@@ -16,6 +16,7 @@ extension DataController {
|
||||
}
|
||||
|
||||
entry.moodValue = mood.rawValue
|
||||
entry.timestamp = Date()
|
||||
saveAndRunDataListeners()
|
||||
|
||||
AnalyticsManager.shared.track(.moodUpdated(mood: mood.rawValue))
|
||||
|
||||
@@ -36,7 +36,7 @@ struct SettingsTabView: View {
|
||||
.padding(.top, 8)
|
||||
|
||||
// Upgrade Banner (only show if not subscribed)
|
||||
if !iapManager.isSubscribed {
|
||||
if !iapManager.isSubscribed && !iapManager.bypassSubscription {
|
||||
UpgradeBannerView(
|
||||
showWhyUpgrade: $showWhyUpgrade,
|
||||
showSubscriptionStore: $showSubscriptionStore,
|
||||
|
||||
@@ -67,6 +67,7 @@ struct SettingsContentView: View {
|
||||
#if DEBUG
|
||||
// Debug section
|
||||
debugSectionHeader
|
||||
bypassSubscriptionToggle
|
||||
trialDateButton
|
||||
animationLabButton
|
||||
paywallPreviewButton
|
||||
@@ -211,6 +212,35 @@ struct SettingsContentView: View {
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private var bypassSubscriptionToggle: some View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "lock.open.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.green)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Bypass Subscription")
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text("Hide trial banner & grant full access")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $iapManager.bypassSubscription)
|
||||
.labelsHidden()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
}
|
||||
|
||||
private var trialDateButton: some View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
@@ -1282,6 +1312,7 @@ struct SettingsView: View {
|
||||
Group {
|
||||
Divider()
|
||||
Text("Test builds only")
|
||||
Toggle("Bypass Subscription", isOn: $iapManager.bypassSubscription)
|
||||
addTestDataCell
|
||||
clearDB
|
||||
// fixWeekday
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user