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 = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
1C0DAB51279DB0FB003B1F21 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; };
|
06E4767B5977FAC8B644FC92 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CFAE86F485C853DB3239DD9 /* IntegrationTests.swift */; };
|
||||||
1C0DAB52279DB0FB003B1F22 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; };
|
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 */; };
|
1C9566442EF8F5F70032E68F /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 1C9566432EF8F5F70032E68F /* Algorithms */; };
|
||||||
1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB4D09F28787D8A00902A56 /* StoreKit.framework */; };
|
1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB4D09F28787D8A00902A56 /* StoreKit.framework */; };
|
||||||
1CD90B07278C7DE0001C4FEA /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B06278C7DE0001C4FEA /* Tests_iOS.swift */; };
|
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 */; };
|
1CDEFBBF2F3B8736006AE6A1 /* Configuration.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */; };
|
||||||
1CDEFBC02F3B8736006AE6A1 /* 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 */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -48,6 +53,13 @@
|
|||||||
remoteGlobalIDString = 1CD90B44278C7E7A001C4FEA;
|
remoteGlobalIDString = 1CD90B44278C7E7A001C4FEA;
|
||||||
remoteInfo = FeelsWidgetExtension;
|
remoteInfo = FeelsWidgetExtension;
|
||||||
};
|
};
|
||||||
|
A0B973C7674930232515563A /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 1CD90AE6278C7DDF001C4FEA /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 1CD90AF4278C7DE0001C4FEA;
|
||||||
|
remoteInfo = "Feels (iOS)";
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@@ -65,7 +77,7 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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; };
|
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; };
|
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; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
29E2A2FC314F88244CA946BF /* StreakTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StreakTests.swift; 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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
1C000C162EE93AE3009C9ED5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
1C000C162EE93AE3009C9ED5 /* Exceptions for "Shared" folder in "FeelsWidgetExtension" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
"Color+Codable.swift",
|
"Color+Codable.swift",
|
||||||
@@ -122,7 +140,7 @@
|
|||||||
);
|
);
|
||||||
target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */;
|
target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */;
|
||||||
};
|
};
|
||||||
2166CE8AA7264FC2B4BFAAAC /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
2166CE8AA7264FC2B4BFAAAC /* Exceptions for "Shared" folder in "Feels Watch App" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Models/Mood.swift,
|
Models/Mood.swift,
|
||||||
@@ -137,12 +155,52 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
1C00073D2EE9388A009C9ED5 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2166CE8AA7264FC2B4BFAAAC /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 1C000C162EE93AE3009C9ED5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = "<group>"; };
|
1C00073D2EE9388A009C9ED5 /* Shared */ = {
|
||||||
1C0009922EE938FC009C9ED5 /* FeelsWidget2 */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = FeelsWidget2; sourceTree = "<group>"; };
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
579031D619ED4B989145EEB1 /* Feels Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Feels Watch App"; sourceTree = "<group>"; };
|
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 */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
0DC68E3188164EBC373A6BF3 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
4F1C717B7747918A459322CB /* Foundation.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
1CD90AF2278C7DE0001C4FEA /* Frameworks */ = {
|
1CD90AF2278C7DE0001C4FEA /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -199,9 +257,9 @@
|
|||||||
1CD90AE5278C7DDF001C4FEA = {
|
1CD90AE5278C7DDF001C4FEA = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App/Feels Watch App.entitlements */,
|
B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App.entitlements */,
|
||||||
B60015D02A064FF582E232FD /* Feels Watch App/Feels Watch AppDebug.entitlements */,
|
B60015D02A064FF582E232FD /* Feels Watch AppDebug.entitlements */,
|
||||||
1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */,
|
1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */,
|
||||||
1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */,
|
1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */,
|
||||||
1CD90B6A278C7F75001C4FEA /* Feels (iOS).entitlements */,
|
1CD90B6A278C7F75001C4FEA /* Feels (iOS).entitlements */,
|
||||||
1CD90B70278C8000001C4FEA /* Feels (iOS)Dev.entitlements */,
|
1CD90B70278C8000001C4FEA /* Feels (iOS)Dev.entitlements */,
|
||||||
@@ -216,6 +274,7 @@
|
|||||||
1CD90B11278C7DE0001C4FEA /* Tests macOS */,
|
1CD90B11278C7DE0001C4FEA /* Tests macOS */,
|
||||||
1CD90B46278C7E7A001C4FEA /* Frameworks */,
|
1CD90B46278C7E7A001C4FEA /* Frameworks */,
|
||||||
1CD90AF6278C7DE0001C4FEA /* Products */,
|
1CD90AF6278C7DE0001C4FEA /* Products */,
|
||||||
|
38D005587E22737DC6291955 /* FeelsTests */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -228,6 +287,7 @@
|
|||||||
1CD90B02278C7DE0001C4FEA /* Tests iOS.xctest */,
|
1CD90B02278C7DE0001C4FEA /* Tests iOS.xctest */,
|
||||||
1CD90B0E278C7DE0001C4FEA /* Tests macOS.xctest */,
|
1CD90B0E278C7DE0001C4FEA /* Tests macOS.xctest */,
|
||||||
1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */,
|
1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */,
|
||||||
|
DA0D74ACDD741CFA1F14F50F /* FeelsTests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -265,10 +325,31 @@
|
|||||||
1CD90B6B278C7F78001C4FEA /* CloudKit.framework */,
|
1CD90B6B278C7F78001C4FEA /* CloudKit.framework */,
|
||||||
1CD90B47278C7E7A001C4FEA /* WidgetKit.framework */,
|
1CD90B47278C7E7A001C4FEA /* WidgetKit.framework */,
|
||||||
1CD90B49278C7E7A001C4FEA /* SwiftUI.framework */,
|
1CD90B49278C7E7A001C4FEA /* SwiftUI.framework */,
|
||||||
|
88F4C25CA0D11FB136B0B8A6 /* iOS */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
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 */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -367,8 +448,6 @@
|
|||||||
1C0009922EE938FC009C9ED5 /* FeelsWidget2 */,
|
1C0009922EE938FC009C9ED5 /* FeelsWidget2 */,
|
||||||
);
|
);
|
||||||
name = FeelsWidgetExtension;
|
name = FeelsWidgetExtension;
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = FeelsWidgetExtension;
|
productName = FeelsWidgetExtension;
|
||||||
productReference = 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */;
|
productReference = 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */;
|
||||||
productType = "com.apple.product-type.app-extension";
|
productType = "com.apple.product-type.app-extension";
|
||||||
@@ -389,12 +468,28 @@
|
|||||||
579031D619ED4B989145EEB1 /* Feels Watch App */,
|
579031D619ED4B989145EEB1 /* Feels Watch App */,
|
||||||
);
|
);
|
||||||
name = "Feels Watch App";
|
name = "Feels Watch App";
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = "Feels Watch App";
|
productName = "Feels Watch App";
|
||||||
productReference = 1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */;
|
productReference = 1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -425,6 +520,10 @@
|
|||||||
B1DB9E6543DE4A009DB00916 = {
|
B1DB9E6543DE4A009DB00916 = {
|
||||||
CreatedOnToolsVersion = 15.0;
|
CreatedOnToolsVersion = 15.0;
|
||||||
};
|
};
|
||||||
|
B375A511826E3AB53E2CF51A = {
|
||||||
|
CreatedOnToolsVersion = 16.0;
|
||||||
|
TestTargetID = 1CD90AF4278C7DE0001C4FEA;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 1CD90AE9278C7DDF001C4FEA /* Build configuration list for PBXProject "Feels" */;
|
buildConfigurationList = 1CD90AE9278C7DDF001C4FEA /* Build configuration list for PBXProject "Feels" */;
|
||||||
@@ -456,6 +555,7 @@
|
|||||||
1CD90AFA278C7DE0001C4FEA /* Feels (macOS) */,
|
1CD90AFA278C7DE0001C4FEA /* Feels (macOS) */,
|
||||||
1CD90B01278C7DE0001C4FEA /* Tests iOS */,
|
1CD90B01278C7DE0001C4FEA /* Tests iOS */,
|
||||||
1CD90B0D278C7DE0001C4FEA /* Tests macOS */,
|
1CD90B0D278C7DE0001C4FEA /* Tests macOS */,
|
||||||
|
B375A511826E3AB53E2CF51A /* FeelsTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -472,7 +572,7 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
1C0DAB51279DB0FB003B1F21 /* Feels/Localizable.xcstrings in Resources */,
|
1C0DAB51279DB0FB003B1F21 /* Localizable.xcstrings in Resources */,
|
||||||
1CDEFBBF2F3B8736006AE6A1 /* Configuration.storekit in Resources */,
|
1CDEFBBF2F3B8736006AE6A1 /* Configuration.storekit in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -503,7 +603,14 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
1CDEFBC02F3B8736006AE6A1 /* Configuration.storekit in Resources */,
|
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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -556,6 +663,17 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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 */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
@@ -574,6 +692,12 @@
|
|||||||
target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */;
|
target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */;
|
||||||
targetProxy = 1CD90B54278C7E7A001C4FEA /* PBXContainerItemProxy */;
|
targetProxy = 1CD90B54278C7E7A001C4FEA /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
946F2D1B29B91CD7DB732908 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
name = "Feels (iOS)";
|
||||||
|
target = 1CD90AF4278C7DE0001C4FEA /* Feels (iOS) */;
|
||||||
|
targetProxy = A0B973C7674930232515563A /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -1020,6 +1144,24 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */ = {
|
67FBFEE92D1D4F8BBFBF7B1D /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -1052,6 +1194,25 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -1118,6 +1279,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
611E0B1E1241C11626465A8D /* Build configuration list for PBXNativeTarget "FeelsTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
C9B28244C1A36D4F2FE7E61A /* Release */,
|
||||||
|
298BB8B78DE12C7707210902 /* Debug */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
|||||||
@@ -32,9 +32,9 @@
|
|||||||
skipped = "NO">
|
skipped = "NO">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "1CD90B01278C7DE0001C4FEA"
|
BlueprintIdentifier = "B375A511826E3AB53E2CF51A"
|
||||||
BuildableName = "Tests iOS.xctest"
|
BuildableName = "FeelsTests.xctest"
|
||||||
BlueprintName = "Tests iOS"
|
BlueprintName = "FeelsTests"
|
||||||
ReferencedContainer = "container:Feels.xcodeproj">
|
ReferencedContainer = "container:Feels.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</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" : {
|
"Cancel" : {
|
||||||
"comment" : "The text for a button that dismisses the current view.",
|
"comment" : "The text for a button that dismisses the current view.",
|
||||||
"isCommentAutoGenerated" : true,
|
"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" : {
|
"Date Range" : {
|
||||||
"comment" : "A label describing the date range for the mood data being exported.",
|
"comment" : "A label describing the date range for the mood data being exported.",
|
||||||
"isCommentAutoGenerated" : true,
|
"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?" : {
|
"How are you feeling?" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -11003,6 +11019,10 @@
|
|||||||
"comment" : "A button label that pauses a live activity.",
|
"comment" : "A button label that pauses a live activity.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Payment Issue" : {
|
||||||
|
"comment" : "A label indicating that there is a payment issue with a subscription.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Paywall Styles" : {
|
"Paywall Styles" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"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" : {
|
"share_view_all_moods_total_template_title" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"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" : {
|
"Top Mood" : {
|
||||||
"comment" : "A label displayed above the top mood value in the month card.",
|
"comment" : "A label displayed above the top mood value in the month card.",
|
||||||
"isCommentAutoGenerated" : true,
|
"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" : {
|
"Your Personal\nDiary" : {
|
||||||
"comment" : "A title describing the main feature of the premium subscription: a personal diary.",
|
"comment" : "A title describing the main feature of the premium subscription: a personal diary.",
|
||||||
"isCommentAutoGenerated" : true,
|
"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
|
// MARK: - Debug Toggle
|
||||||
|
|
||||||
/// Set to `true` to bypass all subscription checks and grant full access (for development only)
|
/// 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
|
#if DEBUG
|
||||||
static let bypassSubscription = false
|
@Published var bypassSubscription: Bool {
|
||||||
|
didSet { UserDefaults.standard.set(bypassSubscription, forKey: "debug_bypassSubscription") }
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
static let bypassSubscription = false
|
let bypassSubscription = false
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
@@ -96,7 +98,7 @@ class IAPManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var hasFullAccess: Bool {
|
var hasFullAccess: Bool {
|
||||||
if Self.bypassSubscription { return true }
|
if bypassSubscription { return true }
|
||||||
switch state {
|
switch state {
|
||||||
case .subscribed, .billingRetry, .gracePeriod, .inTrial:
|
case .subscribed, .billingRetry, .gracePeriod, .inTrial:
|
||||||
return true
|
return true
|
||||||
@@ -106,7 +108,7 @@ class IAPManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var shouldShowPaywall: Bool {
|
var shouldShowPaywall: Bool {
|
||||||
if Self.bypassSubscription { return false }
|
if bypassSubscription { return false }
|
||||||
switch state {
|
switch state {
|
||||||
case .trialExpired, .expired, .revoked:
|
case .trialExpired, .expired, .revoked:
|
||||||
return true
|
return true
|
||||||
@@ -116,6 +118,7 @@ class IAPManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var shouldShowTrialWarning: Bool {
|
var shouldShowTrialWarning: Bool {
|
||||||
|
if bypassSubscription { return false }
|
||||||
if case .inTrial = state { return true }
|
if case .inTrial = state { return true }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -137,6 +140,9 @@ class IAPManager: ObservableObject {
|
|||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
#if DEBUG
|
||||||
|
self.bypassSubscription = UserDefaults.standard.bool(forKey: "debug_bypassSubscription")
|
||||||
|
#endif
|
||||||
restoreCachedSubscriptionState()
|
restoreCachedSubscriptionState()
|
||||||
updateListenerTask = listenForTransactions()
|
updateListenerTask = listenForTransactions()
|
||||||
|
|
||||||
@@ -219,7 +225,7 @@ class IAPManager: ObservableObject {
|
|||||||
|
|
||||||
/// Sync subscription status to UserDefaults for widget access
|
/// Sync subscription status to UserDefaults for widget access
|
||||||
private func syncSubscriptionStatusToUserDefaults() {
|
private func syncSubscriptionStatusToUserDefaults() {
|
||||||
let accessValue = Self.bypassSubscription ? true : hasFullAccess
|
let accessValue = bypassSubscription ? true : hasFullAccess
|
||||||
GroupUserDefaults.groupDefaults.set(accessValue, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
|
GroupUserDefaults.groupDefaults.set(accessValue, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,7 +379,7 @@ class IAPManager: ObservableObject {
|
|||||||
case .unknown:
|
case .unknown:
|
||||||
status = "unknown"
|
status = "unknown"
|
||||||
isSubscribed = false
|
isSubscribed = false
|
||||||
hasFullAccess = Self.bypassSubscription
|
hasFullAccess = bypassSubscription
|
||||||
willAutoRenew = nil
|
willAutoRenew = nil
|
||||||
isInGracePeriod = nil
|
isInGracePeriod = nil
|
||||||
trialDaysRemaining = nil
|
trialDaysRemaining = nil
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ final class DataController: ObservableObject {
|
|||||||
return try? modelContext.fetch(descriptor).first
|
return try? modelContext.fetch(descriptor).first
|
||||||
}
|
}
|
||||||
|
|
||||||
private init() {
|
init(container: ModelContainer? = nil) {
|
||||||
container = SharedModelContainer.createWithFallback(useCloudKit: true)
|
self.container = container ?? SharedModelContainer.createWithFallback(useCloudKit: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ extension DataController {
|
|||||||
|
|
||||||
var descriptor = FetchDescriptor<MoodEntryModel>(
|
var descriptor = FetchDescriptor<MoodEntryModel>(
|
||||||
predicate: #Predicate { entry in
|
predicate: #Predicate { entry in
|
||||||
entry.forDate >= startDate && entry.forDate <= endDate
|
entry.forDate >= startDate && entry.forDate < endDate
|
||||||
},
|
},
|
||||||
sortBy: [SortDescriptor(\.forDate, order: .forward)]
|
sortBy: [SortDescriptor(\.forDate, order: .forward)]
|
||||||
)
|
)
|
||||||
@@ -66,7 +66,8 @@ extension DataController {
|
|||||||
return (0, nil)
|
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 }
|
.filter { $0.mood != .missing && $0.mood != .placeholder }
|
||||||
|
|
||||||
guard !entries.isEmpty else { return (0, nil) }
|
guard !entries.isEmpty else { return (0, nil) }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ extension DataController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry.moodValue = mood.rawValue
|
entry.moodValue = mood.rawValue
|
||||||
|
entry.timestamp = Date()
|
||||||
saveAndRunDataListeners()
|
saveAndRunDataListeners()
|
||||||
|
|
||||||
AnalyticsManager.shared.track(.moodUpdated(mood: mood.rawValue))
|
AnalyticsManager.shared.track(.moodUpdated(mood: mood.rawValue))
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ struct SettingsTabView: View {
|
|||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
|
||||||
// Upgrade Banner (only show if not subscribed)
|
// Upgrade Banner (only show if not subscribed)
|
||||||
if !iapManager.isSubscribed {
|
if !iapManager.isSubscribed && !iapManager.bypassSubscription {
|
||||||
UpgradeBannerView(
|
UpgradeBannerView(
|
||||||
showWhyUpgrade: $showWhyUpgrade,
|
showWhyUpgrade: $showWhyUpgrade,
|
||||||
showSubscriptionStore: $showSubscriptionStore,
|
showSubscriptionStore: $showSubscriptionStore,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ struct SettingsContentView: View {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Debug section
|
// Debug section
|
||||||
debugSectionHeader
|
debugSectionHeader
|
||||||
|
bypassSubscriptionToggle
|
||||||
trialDateButton
|
trialDateButton
|
||||||
animationLabButton
|
animationLabButton
|
||||||
paywallPreviewButton
|
paywallPreviewButton
|
||||||
@@ -211,6 +212,35 @@ struct SettingsContentView: View {
|
|||||||
.padding(.horizontal, 4)
|
.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 {
|
private var trialDateButton: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
theme.currentTheme.secondaryBGColor
|
theme.currentTheme.secondaryBGColor
|
||||||
@@ -1282,6 +1312,7 @@ struct SettingsView: View {
|
|||||||
Group {
|
Group {
|
||||||
Divider()
|
Divider()
|
||||||
Text("Test builds only")
|
Text("Test builds only")
|
||||||
|
Toggle("Bypass Subscription", isOn: $iapManager.bypassSubscription)
|
||||||
addTestDataCell
|
addTestDataCell
|
||||||
clearDB
|
clearDB
|
||||||
// fixWeekday
|
// fixWeekday
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user