diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1fee4a3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)", + "Bash(xcodebuild:*)" + ] + } +} diff --git a/Configuration.storekit b/Configuration.storekit index be4882d..629f75a 100644 --- a/Configuration.storekit +++ b/Configuration.storekit @@ -1,4 +1,14 @@ { + "appPolicies" : { + "eula" : "", + "policies" : [ + { + "locale" : "en_US", + "policyText" : "", + "policyURL" : "" + } + ] + }, "identifier" : "00CCEDCC", "nonRenewingSubscriptions" : [ @@ -7,7 +17,53 @@ ], "settings" : { - + "_askToBuyEnabled" : false, + "_billingGracePeriodEnabled" : false, + "_billingIssuesEnabled" : false, + "_disableDialogs" : false, + "_failTransactionsEnabled" : false, + "_locale" : "en_US", + "_renewalBillingIssuesEnabled" : false, + "_storefront" : "USA", + "_storeKitErrors" : [ + { + "enabled" : false, + "name" : "Load Products" + }, + { + "enabled" : false, + "name" : "Purchase" + }, + { + "enabled" : false, + "name" : "Verification" + }, + { + "enabled" : false, + "name" : "App Store Sync" + }, + { + "enabled" : false, + "name" : "Subscription Status" + }, + { + "enabled" : false, + "name" : "App Transaction" + }, + { + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ], + "_timeRate" : 0 }, "subscriptionGroups" : [ { @@ -36,11 +92,14 @@ "locale" : "en_US" } ], - "productID" : "com.88oakapps.ifeel.IAP.subscriptions.weekly", + "productID" : "com.tt.ifeel.IAP.subscriptions.weekly", "recurringSubscriptionPeriod" : "P1W", "referenceName" : "Weekly", "subscriptionGroupID" : "2CFE4C4F", - "type" : "RecurringSubscription" + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] }, { "adHocOffers" : [ @@ -61,11 +120,14 @@ "locale" : "en_US" } ], - "productID" : "com.88oakapps.ifeel.IAP.subscriptions.monthly", + "productID" : "com.tt.ifeel.IAP.subscriptions.monthly", "recurringSubscriptionPeriod" : "P1M", "referenceName" : "Monthly", "subscriptionGroupID" : "2CFE4C4F", - "type" : "RecurringSubscription" + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] }, { "adHocOffers" : [ @@ -86,17 +148,20 @@ "locale" : "en_US" } ], - "productID" : "com.88oakapps.ifeel.IAP.subscriptions.yearly", + "productID" : "com.tt.ifeel.IAP.subscriptions.yearly", "recurringSubscriptionPeriod" : "P1Y", "referenceName" : "Yearly", "subscriptionGroupID" : "2CFE4C4F", - "type" : "RecurringSubscription" + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] } ] } ], "version" : { - "major" : 1, - "minor" : 2 + "major" : 4, + "minor" : 0 } } diff --git a/Feels (iOS).entitlements b/Feels (iOS).entitlements index 63fe57e..2393105 100644 --- a/Feels (iOS).entitlements +++ b/Feels (iOS).entitlements @@ -2,11 +2,9 @@ - aps-environment - development com.apple.developer.icloud-container-identifiers - iCloud.com.88oakapps.ifeel + iCloud.com.tt.ifeelDebug com.apple.developer.icloud-services @@ -14,7 +12,7 @@ com.apple.security.application-groups - group.com.88oakapps.ifeel + group.com.tt.ifeelDebug diff --git a/Feels (iOS)Dev.entitlements b/Feels (iOS)Dev.entitlements index ff8e656..9a8746e 100644 --- a/Feels (iOS)Dev.entitlements +++ b/Feels (iOS)Dev.entitlements @@ -6,7 +6,7 @@ development com.apple.developer.icloud-container-identifiers - iCloud.com.88oakapps.ifeelDebug + iCloud.com.tt.ifeelDebug com.apple.developer.icloud-services @@ -14,7 +14,7 @@ com.apple.security.application-groups - group.com.88oakapps.ifeelDebug + group.com.tt.ifeel.ifeelDebug diff --git a/Feels--iOS--Info.plist b/Feels--iOS--Info.plist index 521cd4b..1c2cf79 100644 --- a/Feels--iOS--Info.plist +++ b/Feels--iOS--Info.plist @@ -4,10 +4,19 @@ BGTaskSchedulerPermittedIdentifiers - com.88oak.Feels.dbUpdateMissing + com.tt.ifeel.dbUpdateMissing + + CFBundleURLTypes + + + CFBundleURLName + com.tt.ifeel + CFBundleURLSchemes + + feels + + - ITSAppUsesNonExemptEncryption - UIBackgroundModes fetch diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj index 37c7e81..11e69c9 100644 --- a/Feels.xcodeproj/project.pbxproj +++ b/Feels.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 1C0007392EE9339E009C9ED5 /* FeelsSubscriptionStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0007382EE9339E009C9ED5 /* FeelsSubscriptionStoreView.swift */; }; + 1C00073A2EE933B3009C9ED5 /* FeelsSubscriptionStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0007382EE9339E009C9ED5 /* FeelsSubscriptionStoreView.swift */; }; + 1C00073C2EE9374A009C9ED5 /* FeelsVoteWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C00073B2EE9374A009C9ED5 /* FeelsVoteWidget.swift */; }; 1C02589C27B9677A00EB91AC /* CreateWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C02589B27B9677A00EB91AC /* CreateWidgetView.swift */; }; 1C04488727C1C81D00D22444 /* PersonalityPackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C04488627C1C81D00D22444 /* PersonalityPackable.swift */; }; 1C04488827C1CD8C00D22444 /* PersonalityPackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C04488627C1C81D00D22444 /* PersonalityPackable.swift */; }; @@ -68,9 +71,7 @@ 1C414C0F27D51FB500BC1720 /* EntryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C414C0E27D51FB500BC1720 /* EntryListView.swift */; }; 1C414C2A27DB1AF900BC1720 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1C414C2927DB1AF900BC1720 /* GoogleService-Info.plist */; }; 1C414C2B27DB1AF900BC1720 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1C414C2927DB1AF900BC1720 /* GoogleService-Info.plist */; }; - 1C414C2E27DB1B9B00BC1720 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 1C414C2D27DB1B9B00BC1720 /* FirebaseAnalyticsWithoutAdIdSupport */; }; 1C414C3027DB1C2400BC1720 /* EventLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C414C2F27DB1C2400BC1720 /* EventLogger.swift */; }; - 1C414C3327DB1CCE00BC1720 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = 1C414C3227DB1CCE00BC1720 /* FirebaseAnalyticsWithoutAdIdSupport */; }; 1C4FF3BB27BEDDF000BE8F34 /* ShowBasedOnVoteLogics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4FF3BA27BEDDF000BE8F34 /* ShowBasedOnVoteLogics.swift */; }; 1C4FF3BC27BEDF6600BE8F34 /* ShowBasedOnVoteLogics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4FF3BA27BEDDF000BE8F34 /* ShowBasedOnVoteLogics.swift */; }; 1C4FF3BE27BEDF9100BE8F34 /* PersistenceHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4FF3BD27BEDF9100BE8F34 /* PersistenceHelper.swift */; }; @@ -128,8 +129,6 @@ 1CB4D09C2877A36400902A56 /* PurchaseButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CB4D09B2877A36400902A56 /* PurchaseButtonView.swift */; }; 1CB4D09D2877A36400902A56 /* PurchaseButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CB4D09B2877A36400902A56 /* PurchaseButtonView.swift */; }; 1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB4D09F28787D8A00902A56 /* StoreKit.framework */; }; - 1CB4D0A22878B69100902A56 /* StatusInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CB4D0A12878B69100902A56 /* StatusInfoView.swift */; }; - 1CB4D0A32878B69100902A56 /* StatusInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CB4D0A12878B69100902A56 /* StatusInfoView.swift */; }; 1CC469AA278F30A0003E0C6E /* BGTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC469A9278F30A0003E0C6E /* BGTask.swift */; }; 1CC469AC27907D48003E0C6E /* DayChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC469AB27907D48003E0C6E /* DayChartView.swift */; }; 1CD90B07278C7DE0001C4FEA /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B06278C7DE0001C4FEA /* Tests_iOS.swift */; }; @@ -148,7 +147,7 @@ 1CD90B50278C7E7A001C4FEA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1CD90B4F278C7E7A001C4FEA /* Assets.xcassets */; }; 1CD90B52278C7E7A001C4FEA /* FeelsWidget.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B4E278C7E7A001C4FEA /* FeelsWidget.intentdefinition */; }; 1CD90B53278C7E7A001C4FEA /* FeelsWidget.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B4E278C7E7A001C4FEA /* FeelsWidget.intentdefinition */; }; - 1CD90B56278C7E7A001C4FEA /* FeelsWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 1CD90B56278C7E7A001C4FEA /* FeelsWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 1CD90B5D278C7EAD001C4FEA /* Random.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B5C278C7EAD001C4FEA /* Random.swift */; }; 1CD90B5F278C7EAD001C4FEA /* Random.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B5C278C7EAD001C4FEA /* Random.swift */; }; 1CD90B63278C7EBA001C4FEA /* Mood.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B61278C7EBA001C4FEA /* Mood.swift */; }; @@ -192,20 +191,22 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - 1CD90B5A278C7E7A001C4FEA /* Embed App Extensions */ = { + 1CD90B5A278C7E7A001C4FEA /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( - 1CD90B56278C7E7A001C4FEA /* FeelsWidgetExtension.appex in Embed App Extensions */, + 1CD90B56278C7E7A001C4FEA /* FeelsWidgetExtension.appex in Embed Foundation Extensions */, ); - name = "Embed App Extensions"; + name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 1C0007382EE9339E009C9ED5 /* FeelsSubscriptionStoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeelsSubscriptionStoreView.swift; sourceTree = ""; }; + 1C00073B2EE9374A009C9ED5 /* FeelsVoteWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeelsVoteWidget.swift; sourceTree = ""; }; 1C02589B27B9677A00EB91AC /* CreateWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CreateWidgetView.swift; path = ../CustomIcon/CreateWidgetView.swift; sourceTree = ""; }; 1C04488627C1C81D00D22444 /* PersonalityPackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityPackable.swift; sourceTree = ""; }; 1C04488927C2ABD500D22444 /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = ""; }; @@ -289,7 +290,6 @@ 1CB4D09B2877A36400902A56 /* PurchaseButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseButtonView.swift; sourceTree = ""; }; 1CB4D09E28787B3C00902A56 /* Configuration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Configuration.storekit; sourceTree = ""; }; 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; }; - 1CB4D0A12878B69100902A56 /* StatusInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusInfoView.swift; sourceTree = ""; }; 1CC03FA627B5865600B530AF /* Shared 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Shared 2.xcdatamodel"; sourceTree = ""; }; 1CC469A9278F30A0003E0C6E /* BGTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGTask.swift; sourceTree = ""; }; 1CC469AB27907D48003E0C6E /* DayChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayChartView.swift; sourceTree = ""; }; @@ -336,7 +336,6 @@ 1CD90B6C278C7F78001C4FEA /* CloudKit.framework in Frameworks */, 1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */, 1C2618FA2795E41D00FDC148 /* Charts in Frameworks */, - 1C414C2E27DB1B9B00BC1720 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -368,7 +367,6 @@ 1CD90B6E278C7F8B001C4FEA /* CloudKit.framework in Frameworks */, 1CD90B4A278C7E7A001C4FEA /* SwiftUI.framework in Frameworks */, 1CD90B48278C7E7A001C4FEA /* WidgetKit.framework in Frameworks */, - 1C414C3327DB1CCE00BC1720 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -523,6 +521,7 @@ 1CAD602A27A5C1C800C520BD /* Views */ = { isa = PBXGroup; children = ( + 1C0007382EE9339E009C9ED5 /* FeelsSubscriptionStoreView.swift */, 1C358FB927B35252002C83A6 /* ActivityViewController.swift */, 1CAD602F27A5C1C800C520BD /* AddMoodHeaderView.swift */, 1CAD603127A5C1C800C520BD /* BGView.swift */, @@ -546,7 +545,6 @@ 1C04489527C2CB1A00D22444 /* Sharing */, 1C358FB427B0ADF3002C83A6 /* SharingTemplates */, 1CAD602B27A5C1C800C520BD /* SmallRollUpHeaderView.swift */, - 1CB4D0A12878B69100902A56 /* StatusInfoView.swift */, 1CAD603D27A6ECCD00C520BD /* SwitchableView.swift */, 1C04489427C2CAD100D22444 /* YearView */, ); @@ -657,6 +655,7 @@ 1CD90B4E278C7E7A001C4FEA /* FeelsWidget.intentdefinition */, 1CD90B4F278C7E7A001C4FEA /* Assets.xcassets */, 1CD90B51278C7E7A001C4FEA /* Info.plist */, + 1C00073B2EE9374A009C9ED5 /* FeelsVoteWidget.swift */, ); path = FeelsWidget; sourceTree = ""; @@ -692,7 +691,7 @@ 1CD90AF1278C7DE0001C4FEA /* Sources */, 1CD90AF2278C7DE0001C4FEA /* Frameworks */, 1CD90AF3278C7DE0001C4FEA /* Resources */, - 1CD90B5A278C7E7A001C4FEA /* Embed App Extensions */, + 1CD90B5A278C7E7A001C4FEA /* Embed Foundation Extensions */, ); buildRules = ( ); @@ -703,7 +702,6 @@ packageProductDependencies = ( 1C2618F92795E41D00FDC148 /* Charts */, 1C747CC8279F06EB00762CBD /* CloudKitSyncMonitor */, - 1C414C2D27DB1B9B00BC1720 /* FirebaseAnalyticsWithoutAdIdSupport */, ); productName = "Feels (iOS)"; productReference = 1CD90AF5278C7DE0001C4FEA /* iFeels.app */; @@ -776,7 +774,6 @@ ); name = FeelsWidgetExtension; packageProductDependencies = ( - 1C414C3227DB1CCE00BC1720 /* FirebaseAnalyticsWithoutAdIdSupport */, ); productName = FeelsWidgetExtension; productReference = 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */; @@ -790,7 +787,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1320; - LastUpgradeCheck = 1330; + LastUpgradeCheck = 2610; TargetAttributes = { 1CD90AF4278C7DE0001C4FEA = { CreatedOnToolsVersion = 13.2.1; @@ -824,7 +821,6 @@ packageReferences = ( 1C2618F82795E41D00FDC148 /* XCRemoteSwiftPackageReference "ChartsPackage" */, 1C747CC7279F06EB00762CBD /* XCRemoteSwiftPackageReference "CloudKitSyncMonitor" */, - 1C414C2C27DB1B9B00BC1720 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); productRefGroup = 1CD90AF6278C7DE0001C4FEA /* Products */; projectDirPath = ""; @@ -892,7 +888,6 @@ 1CA03773279A293D00D26164 /* OnboardingTime.swift in Sources */, 1CAD603927A5C1C800C520BD /* HeaderPercView.swift in Sources */, 1C0A3C8F27FD445000FF37FF /* OnboardingCustomizeOne.swift in Sources */, - 1CB4D0A22878B69100902A56 /* StatusInfoView.swift in Sources */, 1CAD603C27A5C1C800C520BD /* HeaderStatsView.swift in Sources */, 1CAD603827A5C1C800C520BD /* AddMoodHeaderView.swift in Sources */, 1CA0377C279B605000D26164 /* OnboardingWrapup.swift in Sources */, @@ -970,6 +965,7 @@ 1C2162EE27C15191004353D1 /* MoodEntryFunctions.swift in Sources */, 1C361F0A27C0356000E832FC /* MonthView.swift in Sources */, 1C361F1427C03C8600E832FC /* OnboardingDataDataManager.swift in Sources */, + 1C0007392EE9339E009C9ED5 /* FeelsSubscriptionStoreView.swift in Sources */, 1C358FAD27ADD0C3002C83A6 /* Theme.swift in Sources */, 1C718C7027F611C900A8F9FE /* DaysFilterClass.swift in Sources */, 1C95ABCC27E6FA7200509BD3 /* DiamondView.swift in Sources */, @@ -1020,6 +1016,7 @@ 1C04488B27C2ABDE00D22444 /* IconView.swift in Sources */, 1C04489A27C3F24F00D22444 /* Color+Codable.swift in Sources */, 1C361F1127C03C3D00E832FC /* OnboardingTime.swift in Sources */, + 1C00073A2EE933B3009C9ED5 /* FeelsSubscriptionStoreView.swift in Sources */, 1C76E86F27C882A400ADEE1F /* SharingImageModels.swift in Sources */, 1CEC967227B9C9FB00CC8688 /* CustomWidgetView.swift in Sources */, 1C2162F827C16E3C004353D1 /* MoodTintable.swift in Sources */, @@ -1037,6 +1034,7 @@ 1CB101C827B81CAC00D1C033 /* MoodMetrics.swift in Sources */, 1C683FCB2792281400745862 /* Stats.swift in Sources */, 1C718C7127F611C900A8F9FE /* DaysFilterClass.swift in Sources */, + 1C00073C2EE9374A009C9ED5 /* FeelsVoteWidget.swift in Sources */, 1CEC967327B9CA0C00CC8688 /* CustomWidgetModel.swift in Sources */, 1C10E25027A1AB220047948B /* OnboardingDay.swift in Sources */, 1C04488827C1CD8C00D22444 /* PersonalityPackable.swift in Sources */, @@ -1044,7 +1042,6 @@ 1C4FF3C827BEE09E00BE8F34 /* PersistenceADD.swift in Sources */, 1C2162F527C16061004353D1 /* MoodImagable.swift in Sources */, 1C2162EC27C14FC5004353D1 /* Date+Extensions.swift in Sources */, - 1CB4D0A32878B69100902A56 /* StatusInfoView.swift in Sources */, 1C2C5B2B27DEBE260092A308 /* EventLogger.swift in Sources */, 1C4FF3C127BEE06900BE8F34 /* PersistenceGET.swift in Sources */, 1C361F0D27C03BDF00E832FC /* OnboardingData.swift in Sources */, @@ -1129,9 +1126,11 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -1150,6 +1149,7 @@ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -1190,9 +1190,11 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -1204,6 +1206,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; }; @@ -1212,7 +1215,6 @@ 1CD90B23278C7DE0001C4FEA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; @@ -1220,22 +1222,23 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 23; - DEVELOPMENT_TEAM = QND55P4443; + DEVELOPMENT_TEAM = V3PF3M6B6U; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Feels--iOS--Info.plist"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.2; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.1; - PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.ifeelDebug; + MARKETING_VERSION = 1.0.2; + PRODUCT_BUNDLE_IDENTIFIER = com.tt.ifeelDebug; PRODUCT_NAME = iFeels; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; @@ -1248,7 +1251,6 @@ 1CD90B24278C7DE0001C4FEA /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; @@ -1256,22 +1258,23 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 23; - DEVELOPMENT_TEAM = QND55P4443; + DEVELOPMENT_TEAM = V3PF3M6B6U; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Feels--iOS--Info.plist"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.2; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.1; - PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.ifeel; + MARKETING_VERSION = 1.0.2; + PRODUCT_BUNDLE_IDENTIFIER = com.tt.ifeelDebug; PRODUCT_NAME = iFeels; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; @@ -1292,9 +1295,12 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = V3PF3M6B6U; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( @@ -1303,7 +1309,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 12.1; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.88oak.ifeel; + PRODUCT_BUNDLE_IDENTIFIER = com.tt.ifeel; PRODUCT_NAME = Feels; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1321,9 +1327,12 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = V3PF3M6B6U; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( @@ -1332,7 +1341,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 12.1; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.88oak.ifeel; + PRODUCT_BUNDLE_IDENTIFIER = com.tt.ifeelDebug; PRODUCT_NAME = Feels; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1343,7 +1352,6 @@ 1CD90B29278C7DE0001C4FEA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = V3PF3M6B6U; @@ -1363,7 +1371,6 @@ 1CD90B2A278C7DE0001C4FEA /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = V3PF3M6B6U; @@ -1384,9 +1391,9 @@ 1CD90B2C278C7DE0001C4FEA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = V3PF3M6B6U; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 12.1; @@ -1403,9 +1410,9 @@ 1CD90B2D278C7DE0001C4FEA /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = V3PF3M6B6U; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 12.1; @@ -1428,19 +1435,19 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = QND55P4443; + DEVELOPMENT_TEAM = V3PF3M6B6U; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = FeelsWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = iFeelsWidget; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 15.2; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.ifeelDebug.FeelsWidgetDebug; + PRODUCT_BUNDLE_IDENTIFIER = com.tt.ifeelDebug.FeelsWidgetDebug; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; @@ -1460,19 +1467,19 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = QND55P4443; + DEVELOPMENT_TEAM = V3PF3M6B6U; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = FeelsWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = iFeelsWidget; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 15.2; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.ifeel.FeelsWidget; + PRODUCT_BUNDLE_IDENTIFIER = com.tt.ifeelDebug.FeelsWidget; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; @@ -1552,14 +1559,6 @@ kind = branch; }; }; - 1C414C2C27DB1B9B00BC1720 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 8.0.0; - }; - }; 1C747CC7279F06EB00762CBD /* XCRemoteSwiftPackageReference "CloudKitSyncMonitor" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ggruen/CloudKitSyncMonitor"; @@ -1576,16 +1575,6 @@ package = 1C2618F82795E41D00FDC148 /* XCRemoteSwiftPackageReference "ChartsPackage" */; productName = Charts; }; - 1C414C2D27DB1B9B00BC1720 /* FirebaseAnalyticsWithoutAdIdSupport */ = { - isa = XCSwiftPackageProductDependency; - package = 1C414C2C27DB1B9B00BC1720 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAnalyticsWithoutAdIdSupport; - }; - 1C414C3227DB1CCE00BC1720 /* FirebaseAnalyticsWithoutAdIdSupport */ = { - isa = XCSwiftPackageProductDependency; - package = 1C414C2C27DB1B9B00BC1720 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAnalyticsWithoutAdIdSupport; - }; 1C747CC8279F06EB00762CBD /* CloudKitSyncMonitor */ = { isa = XCSwiftPackageProductDependency; package = 1C747CC7279F06EB00762CBD /* XCRemoteSwiftPackageReference "CloudKitSyncMonitor" */; diff --git a/Feels.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Feels.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 32bcb25..905d450 100644 --- a/Feels.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Feels.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,23 +1,6 @@ { + "originHash" : "a94a6f7161636f5a828d77329021c6a57c3834b48e41169d840d1bab50287ba3", "pins" : [ - { - "identity" : "abseil-cpp-swiftpm", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/abseil-cpp-SwiftPM.git", - "state" : { - "revision" : "583de9bd60f66b40e78d08599cc92036c2e7e4e1", - "version" : "0.20220203.2" - } - }, - { - "identity" : "boringssl-swiftpm", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/boringssl-SwiftPM.git", - "state" : { - "revision" : "dd3eda2b05a3f459fc3073695ad1b28659066eab", - "version" : "0.9.1" - } - }, { "identity" : "chartspackage", "kind" : "remoteSourceControl", @@ -36,87 +19,6 @@ "version" : "1.1.1" } }, - { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "111d8d6ad1a1afd6c8e9561d26e55ab1e74fcb42", - "version" : "8.15.0" - } - }, - { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "ef819db8c58657a6ca367322e73f3b6322afe0a2", - "version" : "8.15.0" - } - }, - { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "5056b15c5acbb90cd214fe4d6138bdf5a740e5a8", - "version" : "9.2.0" - } - }, - { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "22907832079d808e82f1182b21af58ef3880666f", - "version" : "7.8.0" - } - }, - { - "identity" : "grpc-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/grpc/grpc-ios.git", - "state" : { - "revision" : "8440b914756e0d26d4f4d054a1c1581daedfc5b6", - "version" : "1.44.3-grpc" - } - }, - { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "4e9bbf2808b8fee444e84a48f5f3c12641987d3e", - "version" : "1.7.2" - } - }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b", - "version" : "1.22.2" - } - }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "7ee9ef9f627d85cbe1b8c4f49a3ed26eed216c77", - "version" : "2.30908.0" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "3e4e743631e86c8c70dbc6efdc7beaa6e90fd3bb", - "version" : "2.1.1" - } - }, { "identity" : "swift-algorithms", "kind" : "remoteSourceControl", @@ -134,16 +36,7 @@ "revision" : "6583ac70c326c3ee080c1d42d9ca3361dca816cd", "version" : "0.1.0" } - }, - { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "b8230909dedc640294d7324d37f4c91ad3dcf177", - "version" : "1.20.1" - } } ], - "version" : 2 + "version" : 3 } diff --git a/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme b/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme index a35d4bd..c15c9bb 100644 --- a/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme +++ b/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme @@ -1,6 +1,6 @@ some IntentResult { + let mood = Mood(rawValue: moodValue) ?? .average + let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) + + // Add mood entry + PersistenceController.shared.add(mood: mood, forDate: votingDate, entryType: .widget) + + // Store last voted date + let dateString = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: votingDate)) + GroupUserDefaults.groupDefaults.set(dateString, forKey: UserDefaultsStore.Keys.lastVotedDate.rawValue) + + // Reload widget timeline + WidgetCenter.shared.reloadTimelines(ofKind: "FeelsVoteWidget") + + return .result() + } +} + +// MARK: - Vote Widget Provider + +struct VoteWidgetProvider: TimelineProvider { + func placeholder(in context: Context) -> VoteWidgetEntry { + VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: false, todaysMood: nil, stats: nil) + } + + func getSnapshot(in context: Context, completion: @escaping (VoteWidgetEntry) -> Void) { + let entry = createEntry() + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = createEntry() + + // Refresh at midnight + let midnight = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!) + let timeline = Timeline(entries: [entry], policy: .after(midnight)) + completion(timeline) + } + + private func createEntry() -> VoteWidgetEntry { + let hasSubscription = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue) + + let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) + let dayStart = Calendar.current.startOfDay(for: votingDate) + let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)! + + // Check if user has voted today + let todayEntry = PersistenceController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first + let hasVotedToday = todayEntry != nil && todayEntry?.mood != .missing && todayEntry?.mood != .placeholder + + // Get today's mood if voted + let todaysMood: Mood? = hasVotedToday ? todayEntry?.mood : nil + + // Get stats for display after voting + var stats: MoodStats? = nil + if hasVotedToday { + let allEntries = PersistenceController.shared.getAllData() + let validEntries = allEntries.filter { $0.mood != .missing && $0.mood != .placeholder } + let totalCount = validEntries.count + + if totalCount > 0 { + var moodCounts: [Mood: Int] = [:] + for entry in validEntries { + moodCounts[entry.mood, default: 0] += 1 + } + stats = MoodStats(totalEntries: totalCount, moodCounts: moodCounts) + } + } + + return VoteWidgetEntry( + date: Date(), + hasSubscription: hasSubscription, + hasVotedToday: hasVotedToday, + todaysMood: todaysMood, + stats: stats + ) + } +} + +// MARK: - Stats Model + +struct MoodStats { + let totalEntries: Int + let moodCounts: [Mood: Int] + + func percentage(for mood: Mood) -> Double { + guard totalEntries > 0 else { return 0 } + return Double(moodCounts[mood, default: 0]) / Double(totalEntries) * 100 + } +} + +// MARK: - Timeline Entry + +struct VoteWidgetEntry: TimelineEntry { + let date: Date + let hasSubscription: Bool + let hasVotedToday: Bool + let todaysMood: Mood? + let stats: MoodStats? +} + +// MARK: - Widget Views + +struct FeelsVoteWidgetEntryView: View { + @Environment(\.widgetFamily) var family + var entry: VoteWidgetProvider.Entry + + var body: some View { + Group { + if entry.hasSubscription { + if entry.hasVotedToday { + // Show stats after voting + VotedStatsView(entry: entry) + } else { + // Show voting buttons + VotingView(family: family) + } + } else { + // Non-subscriber view - tap to open app + NonSubscriberView() + } + } + .containerBackground(.fill.tertiary, for: .widget) + } +} + +// MARK: - Voting View (for subscribers who haven't voted) + +struct VotingView: View { + let family: WidgetFamily + let moods: [Mood] = [.horrible, .bad, .average, .good, .great] + + var body: some View { + VStack(spacing: 8) { + Text("How are you feeling?") + .font(.headline) + .foregroundStyle(.primary) + + if family == .systemSmall { + // Compact layout for small widget + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { + ForEach(moods, id: \.rawValue) { mood in + MoodButton(mood: mood, isCompact: true) + } + } + } else { + // Horizontal layout for medium/large + HStack(spacing: 12) { + ForEach(moods, id: \.rawValue) { mood in + MoodButton(mood: mood, isCompact: false) + } + } + } + } + .padding() + } +} + +struct MoodButton: View { + let mood: Mood + let isCompact: Bool + + private var moodTint: MoodTintable.Type { + UserDefaultsStore.moodTintable() + } + + private var moodImages: MoodImagable.Type { + UserDefaultsStore.moodMoodImagable() + } + + var body: some View { + Button(intent: VoteMoodIntent(mood: mood)) { + VStack(spacing: 4) { + moodImages.icon(forMood: mood) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: isCompact ? 28 : 36, height: isCompact ? 28 : 36) + .foregroundColor(moodTint.color(forMood: mood)) + + if !isCompact { + Text(mood.strValue) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .buttonStyle(.plain) + } +} + +// MARK: - Voted Stats View (shown after voting) + +struct VotedStatsView: View { + let entry: VoteWidgetEntry + + private var moodTint: MoodTintable.Type { + UserDefaultsStore.moodTintable() + } + + private var moodImages: MoodImagable.Type { + UserDefaultsStore.moodMoodImagable() + } + + var body: some View { + VStack(spacing: 12) { + // Today's mood + if let mood = entry.todaysMood { + HStack(spacing: 8) { + moodImages.icon(forMood: mood) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 32, height: 32) + .foregroundColor(moodTint.color(forMood: mood)) + + VStack(alignment: .leading, spacing: 2) { + Text("Today") + .font(.caption) + .foregroundStyle(.secondary) + Text(mood.strValue) + .font(.headline) + .foregroundColor(moodTint.color(forMood: mood)) + } + + Spacer() + } + } + + // Stats + if let stats = entry.stats { + Divider() + + VStack(spacing: 4) { + Text("\(stats.totalEntries) entries") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 4) { + ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in + let percentage = stats.percentage(for: mood) + if percentage > 0 { + RoundedRectangle(cornerRadius: 2) + .fill(moodTint.color(forMood: mood)) + .frame(width: max(4, CGFloat(percentage) * 0.8)) + } + } + } + .frame(height: 8) + } + } + } + .padding() + } +} + +// MARK: - Non-Subscriber View + +struct NonSubscriberView: View { + var body: some View { + Link(destination: URL(string: "feels://subscribe")!) { + VStack(spacing: 8) { + Image(systemName: "heart.fill") + .font(.largeTitle) + .foregroundStyle(.pink) + + Text("Track Your Mood") + .font(.headline) + .foregroundStyle(.primary) + + Text("Tap to subscribe") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +// MARK: - Widget Configuration + +struct FeelsVoteWidget: Widget { + let kind: String = "FeelsVoteWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: VoteWidgetProvider()) { entry in + FeelsVoteWidgetEntryView(entry: entry) + } + .configurationDisplayName("Mood Vote") + .description("Quickly rate your mood for today") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +// MARK: - Preview + +#Preview(as: .systemSmall) { + FeelsVoteWidget() +} timeline: { + VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: false, todaysMood: nil, stats: nil) + VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .great, stats: MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1])) + VoteWidgetEntry(date: Date(), hasSubscription: false, hasVotedToday: false, todaysMood: nil, stats: nil) +} diff --git a/FeelsWidget/FeelsWidget.swift b/FeelsWidget/FeelsWidget.swift index ecae3e0..af4365b 100644 --- a/FeelsWidget/FeelsWidget.swift +++ b/FeelsWidget/FeelsWidget.swift @@ -360,6 +360,7 @@ struct FeelsBundle: WidgetBundle { FeelsWidget() FeelsGraphicWidget() FeelsIconWidget() + FeelsVoteWidget() } } diff --git a/FeelsWidgetExtension.entitlements b/FeelsWidgetExtension.entitlements index 63fe57e..2393105 100644 --- a/FeelsWidgetExtension.entitlements +++ b/FeelsWidgetExtension.entitlements @@ -2,11 +2,9 @@ - aps-environment - development com.apple.developer.icloud-container-identifiers - iCloud.com.88oakapps.ifeel + iCloud.com.tt.ifeelDebug com.apple.developer.icloud-services @@ -14,7 +12,7 @@ com.apple.security.application-groups - group.com.88oakapps.ifeel + group.com.tt.ifeelDebug diff --git a/FeelsWidgetExtensionDev.entitlements b/FeelsWidgetExtensionDev.entitlements index ff8e656..8c54224 100644 --- a/FeelsWidgetExtensionDev.entitlements +++ b/FeelsWidgetExtensionDev.entitlements @@ -6,7 +6,7 @@ development com.apple.developer.icloud-container-identifiers - iCloud.com.88oakapps.ifeelDebug + iCloud.com.tt.ifeelDebug com.apple.developer.icloud-services @@ -14,7 +14,8 @@ com.apple.security.application-groups - group.com.88oakapps.ifeelDebug + group.com.tt.ifeelDebug + diff --git a/PROJECT_OVERVIEW.md b/PROJECT_OVERVIEW.md new file mode 100644 index 0000000..ace01d6 --- /dev/null +++ b/PROJECT_OVERVIEW.md @@ -0,0 +1,197 @@ +# Feels - iOS Mood Tracking App + +## Overview + +**Feels** is a daily mood tracking iOS application that allows users to rate their emotional state each day on a 5-point scale (Horrible, Bad, Average, Good, Great) and visualize patterns over time through multiple view modes. + +## Core Features + +- **Daily Mood Tracking**: Rate your day on a 5-point scale +- **Multiple View Modes**: Day (chronological list), Month (calendar grid), Year (aggregate stats) +- **Customization**: Themes, color schemes, icon packs, notification personalities +- **Widgets**: iOS home screen widgets showing recent moods +- **Local Notifications**: Daily reminders with action buttons for quick mood entry +- **Data Export/Import**: CSV format for data portability +- **Subscription Model**: 30-day free trial, then monthly/yearly subscriptions +- **Localization**: English and Spanish + +--- + +## Architecture + +**Pattern**: MVVM (Model-View-ViewModel) with SwiftUI + +### Project Structure + +``` +Feels/ +├── Shared/ # Core app code +│ ├── Models/ # Data models (Mood, Theme, MoodTintable, etc.) +│ ├── Views/ # SwiftUI views organized by feature +│ │ ├── DayView/ # Chronological list view +│ │ ├── MonthView/ # Calendar grid view +│ │ ├── YearView/ # Yearly statistics view +│ │ ├── CustomizeView/ # Settings and customization +│ │ └── Components/ # Reusable UI components +│ ├── Persisence/ # Core Data persistence layer +│ ├── Onboarding/ # First-run experience +│ └── Protocols/ # Protocol definitions +├── FeelsWidget/ # iOS Widget Extension (3 widget types) +├── en.lproj/ # English localization +├── es.lproj/ # Spanish localization +└── Tests iOS/ # Test targets +``` + +--- + +## Key Files + +### App Entry +| File | Purpose | +|------|---------| +| `FeelsApp.swift` | Main app entry, Core Data setup, IAP manager, tab navigation | + +### Data Layer +| File | Purpose | +|------|---------| +| `Feels.xcdatamodeld` | Core Data model with `MoodEntry` entity | +| `Persistence.swift` | Core Data stack, App Group container | +| `PersistenceGET.swift` | Fetch operations | +| `PersistenceADD.swift` | Create/fill missing entries | +| `PersistenceUPDATE.swift` | Update operations | +| `PersistenceDELETE.swift` | Delete operations | + +### Main Views +| File | Purpose | +|------|---------| +| `MainTabView.swift` | Root navigation (Day, Month, Year, Customize tabs) | +| `DayView.swift` | Chronological mood list with edit/delete | +| `MonthView.swift` | Calendar grid with color-coded moods | +| `YearView.swift` | Aggregate yearly statistics | +| `CustomizeView.swift` | Theme, colors, icons, shape settings | + +### Services +| File | Purpose | +|------|---------| +| `IAPManager.swift` | StoreKit 2 subscriptions, 30-day trial | +| `LocalNotification.swift` | Daily reminders with quick actions | +| `BGTask.swift` | Background task for filling missing dates | +| `EventLogger.swift` | Analytics event tracking | + +--- + +## Data Models + +### MoodEntry (Core Data Entity) +``` +- moodValue: Int16 (0-4 for mood ratings) +- forDate: Date (the day being rated) +- timestamp: Date (when entry was created) +- weekDay: Int16 (1-7) +- canEdit, canDelete: Boolean +- entryType: Int16 (header, list, filled-in-missing) +``` + +### Mood Enum +```swift +enum Mood: Int { + case horrible = 0 + case bad = 1 + case average = 2 + case good = 3 + case great = 4 + case missing = 5 + case placeholder = 6 +} +``` + +--- + +## Customization System + +### Themes (`Theme.swift`) +- System, iFeel (gradient), Dark, Light +- Protocol: `Themeable` + +### Color Schemes (`MoodTintable.swift`) +- Default, Neon, Pastel, Custom (user-defined) +- Each mood has primary and secondary colors + +### Icon Packs (`MoodImagable.swift`) +- FontAwesome, Emoji, Hand Emoji +- Protocol: `MoodImagable` + +### Shapes +- Circle, Square, Diamond for calendar visualization + +### Notification Personalities (`PersonalityPackable.swift`) +- "Nice": Friendly text +- "Rude": Sarcastic/aggressive text + +--- + +## Widget System + +Three widget types in `FeelsWidget/`: +1. **FeelsWidget**: Small/Medium/Large showing recent moods +2. **FeelsGraphicWidget**: Small widget with mood graphic +3. **FeelsIconWidget**: Custom icon widget + +Data shared via App Groups: `group.com.88oakapps.ifeel` + +--- + +## Dependencies + +### Apple Frameworks +- SwiftUI, CoreData, WidgetKit, StoreKit, UserNotifications, CloudKit, BackgroundTasks + +### Third-Party +- CloudKitSyncMonitor +- Firebase (GoogleService-Info.plist) + +--- + +## Configuration + +### App Groups +- Production: `group.com.88oakapps.ifeel` +- Debug: `group.com.88oakapps.ifeelDebug` + +### Background Tasks +- Identifier: `com.88oak.Feels.dbUpdateMissing` + +### StoreKit Products +- Monthly subscription +- Yearly subscription +- 30-day free trial + +--- + +## Data Flow + +``` +User Input → Persistence (Core Data) → ViewModel Update → SwiftUI Re-render + ↓ + Widget Update (WidgetCenter.shared.reloadAllTimelines()) +``` + +--- + +## Localization + +| Language | File | +|----------|------| +| English | `en.lproj/Localizable.strings` | +| Spanish | `es.lproj/Localizable.strings` | + +Covers: Onboarding, mood names, UI labels, notifications, settings + +--- + +## Notable Implementation Details + +1. **Automatic Missing Date Fill**: Creates placeholder entries for days without ratings +2. **Entry Types**: `header`, `listView`, `filledInMissing` - tracks how entries were created +3. **Day Filtering**: Users can filter which weekdays appear in visualizations +4. **CSV Export**: Full data portability with all metadata preserved diff --git a/Shared/AppDelegate.swift b/Shared/AppDelegate.swift index 58511c8..9660dba 100644 --- a/Shared/AppDelegate.swift +++ b/Shared/AppDelegate.swift @@ -10,7 +10,7 @@ import UserNotifications import UIKit import WidgetKit import SwiftUI -import Firebase +// import Firebase // Firebase removed class AppDelegate: NSObject, UIApplicationDelegate { private let savedOnboardingData = UserDefaultsStore.getOnboarding() @@ -22,7 +22,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { // PersistenceController.shared.deleteRandomFromLast(numberOfEntries: 10) // GroupUserDefaults.groupDefaults.set(false, forKey: UserDefaultsStore.Keys.showNSFW.rawValue) - FirebaseApp.configure() + // FirebaseApp.configure() // Firebase removed PersistenceController.shared.removeNoForDates() PersistenceController.shared.fillInMissingDates() UNUserNotificationCenter.current().delegate = self diff --git a/Shared/BGTask.swift b/Shared/BGTask.swift index ea40a66..e840e61 100644 --- a/Shared/BGTask.swift +++ b/Shared/BGTask.swift @@ -9,7 +9,7 @@ import Foundation import BackgroundTasks class BGTask { - static let updateDBMissingID = "com.88oak.Feels.dbUpdateMissing" + static let updateDBMissingID = "com.tt.ifeel.dbUpdateMissing" class func runFillInMissingDatesTask(task: BGProcessingTask) { BGTask.scheduleBackgroundProcessing() diff --git a/Shared/EventLogger.swift b/Shared/EventLogger.swift index 1ed2610..17586e0 100644 --- a/Shared/EventLogger.swift +++ b/Shared/EventLogger.swift @@ -6,10 +6,14 @@ // import Foundation -import Firebase +// import Firebase // Firebase removed class EventLogger { static func log(event: String, withData data: [String: Any]? = nil) { - Analytics.logEvent(event, parameters: data) + // Firebase Analytics disabled + // Analytics.logEvent(event, parameters: data) + #if DEBUG + print("[EventLogger] \(event)", data ?? "") + #endif } } diff --git a/Shared/FeelsApp.swift b/Shared/FeelsApp.swift index 1e840b2..fe6792a 100644 --- a/Shared/FeelsApp.swift +++ b/Shared/FeelsApp.swift @@ -17,6 +17,7 @@ struct FeelsApp: App { let persistenceController = PersistenceController.shared @StateObject var iapManager = IAPManager() @AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date() + @State private var showSubscriptionFromWidget = false init() { BGTaskScheduler.shared.cancelAllTaskRequests() @@ -38,14 +39,27 @@ struct FeelsApp: App { customizeView: CustomizeView()) .environment(\.managedObjectContext, persistenceController.viewContext) .environmentObject(iapManager) + .sheet(isPresented: $showSubscriptionFromWidget) { + FeelsSubscriptionStoreView() + .environmentObject(iapManager) + } + .onOpenURL { url in + if url.scheme == "feels" && url.host == "subscribe" { + showSubscriptionFromWidget = true + } + } }.onChange(of: scenePhase) { phase in if phase == .background { //BGTask.scheduleBackgroundProcessing() WidgetCenter.shared.reloadAllTimelines() } - + if phase == .active { UIApplication.shared.applicationIconBadgeNumber = 0 + // Check subscription status on each app launch + Task { + await iapManager.checkSubscriptionStatus() + } } } } diff --git a/Shared/GoogleService-Info.plist b/Shared/GoogleService-Info.plist index 06b9041..4453b48 100644 --- a/Shared/GoogleService-Info.plist +++ b/Shared/GoogleService-Info.plist @@ -13,22 +13,22 @@ PLIST_VERSION 1 BUNDLE_ID - com.88oakapps.ifeel + com.tt.ifeel PROJECT_ID ifeels STORAGE_BUCKET ifeels.appspot.com IS_ADS_ENABLED - + IS_ANALYTICS_ENABLED - + IS_APPINVITE_ENABLED - + IS_GCM_ENABLED - + IS_SIGNIN_ENABLED - + GOOGLE_APP_ID 1:946071058799:ios:10f66b0b5dfe758ab0509a - \ No newline at end of file + diff --git a/Shared/IAPManager.swift b/Shared/IAPManager.swift index ff3c769..61eba37 100644 --- a/Shared/IAPManager.swift +++ b/Shared/IAPManager.swift @@ -1,410 +1,235 @@ -/* - See LICENSE folder for this sample’s licensing information. - - Abstract: - The store class is responsible for requesting products from the App Store and starting purchases. - */ +// +// IAPManager.swift +// Feels +// +// Refactored StoreKit 2 subscription manager with clean state model. +// import Foundation import StoreKit import SwiftUI -typealias Transaction = StoreKit.Transaction -typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo -typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState +// MARK: - Subscription State -public enum StoreError: Error { - case failedVerification +enum SubscriptionState: Equatable { + case unknown + case subscribed(expirationDate: Date?, willAutoRenew: Bool) + case inTrial(daysRemaining: Int) + case trialExpired + case expired } -class IAPManager: ObservableObject { - @Published private(set) var showIAP = false - @Published private(set) var showIAPWarning = false - - @Published private(set) var isPurchasing = false - - @Published private(set) var subscriptions = [Product: (status: [Product.SubscriptionInfo.Status], renewalInfo: RenewalInfo)?]() - private(set) var purchasedProductIDs = Set() - - @AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date() - - @Published private(set) var isLoadingSubscriptions = false - - public var sortedSubscriptionKeysByPriceOptions: [Product] { - subscriptions.keys.sorted(by: { - $0.price < $1.price - }) - } +// MARK: - IAPManager - public var daysLeftBeforeIAP: Int { - let daysSinceInstall = Calendar.current.dateComponents([.day, .hour, .minute, .second], from: firstLaunchDate, to: Date()) - if let days = daysSinceInstall.day { - return 30 - days - } - return 0 - } - - private var shouldShowIAP: Bool { - if shouldShowIAPWarning && daysLeftBeforeIAP <= 0{ - return true - } - +@MainActor +class IAPManager: ObservableObject { + + // MARK: - Constants + + static let subscriptionGroupID = "2CFE4C4F" + + private let productIdentifiers: Set = [ + "com.tt.ifeel.IAP.subscriptions.monthly", + "com.tt.ifeel.IAP.subscriptions.yearly" + ] + + private let trialDays = 30 + + // MARK: - Published State + + @Published private(set) var state: SubscriptionState = .unknown + @Published private(set) var availableProducts: [Product] = [] + @Published private(set) var currentProduct: Product? = nil + @Published private(set) var isLoading = false + + // MARK: - Storage + + @AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) + private var firstLaunchDate = Date() + + // MARK: - Private + + private var updateListenerTask: Task? + + // MARK: - Computed Properties + + var isSubscribed: Bool { + if case .subscribed = state { return true } return false } - - private var shouldShowIAPWarning: Bool { - // if we have't fetch all subscriptions yet use faster - // purchasedProductIDs - if subscriptions.isEmpty { - if purchasedProductIDs.isEmpty { - return true - } else { - return false - } - } else { - if currentSubscription == nil { - return true - } + + var hasFullAccess: Bool { + switch state { + case .subscribed, .inTrial: + return true + case .unknown, .trialExpired, .expired: return false } } - - public var currentSubscription: Product? { - let sortedProducts = subscriptions.keys.sorted(by: { - $0.price > $1.price - }) - // first see if we have a product + sub that is set to autorenew - for product in sortedProducts { - if let _value = subscriptions[product]??.renewalInfo { - if _value.willAutoRenew { - return product - } - } + var shouldShowPaywall: Bool { + switch state { + case .trialExpired, .expired: + return true + case .unknown, .subscribed, .inTrial: + return false } - - // if no auto renew then return - // highest priced that has a sub status - for product in sortedProducts { - if let _ = subscriptions[product]??.status { - return product - } - } - - return nil } - - // for all products return the one that is set - // to auto renew - public var nextRenewllOption: Product? { - if let currentSubscription = currentSubscription, - let info = subscriptions[currentSubscription], - let status = info?.status { - for aStatus in status { - if let renewalInfo = try? checkVerified(aStatus.renewalInfo), - renewalInfo.willAutoRenew { - if let renewToProduct = subscriptions.first(where: { - $0.key.id == renewalInfo.autoRenewPreference - })?.key { - return renewToProduct - } - } - } - } - return nil + + var shouldShowTrialWarning: Bool { + if case .inTrial = state { return true } + return false } - - var updateListenerTask: Task? = nil - - public var expireDate: Date? { - Calendar.current.date(byAdding: .day, value: 30, to: firstLaunchDate) ?? nil + + var daysLeftInTrial: Int { + if case .inTrial(let days) = state { return days } + return 0 } - - private let iapIdentifiers = Set([ -// "com.88oakapps.ifeel.IAP.subscriptions.weekly", - "com.88oakapps.ifeel.IAP.subscriptions.monthly", - "com.88oakapps.ifeel.IAP.subscriptions.yearly" - ]) - - var expireOnTimer: Timer? - + + var trialExpirationDate: Date? { + Calendar.current.date(byAdding: .day, value: trialDays, to: firstLaunchDate) + } + + /// Products sorted by price (lowest first) + var sortedProducts: [Product] { + availableProducts.sorted { $0.price < $1.price } + } + + // MARK: - Initialization + init() { - isLoadingSubscriptions = true - - //Start a transaction listener as close to app launch as possible so you don't miss any transactions. updateListenerTask = listenForTransactions() - - updateEverything() + + Task { + await checkSubscriptionStatus() + } } - + deinit { updateListenerTask?.cancel() } - - public func updateEverything() { - Task { - DispatchQueue.main.async { - self.subscriptions.removeAll() - self.purchasedProductIDs.removeAll() - } - // get current sub from local cache - await updatePurchasedProducts() - - // update local variables to show iap warning / purchase views - self.updateShowVariables() - - // if they have a subscription we dont care about showing the loading indicator - if !self.showIAP { - DispatchQueue.main.async { - self.isLoadingSubscriptions = false - } - } - - // During store initialization, request products from the App Store. - await requestProducts() - - // Deliver products that the customer purchases. - await updateCustomerProductStatus() - - self.updateShowVariables() - - self.setUpdateTimer() - - DispatchQueue.main.async { - self.isLoadingSubscriptions = false - } - } - } + // MARK: - Public Methods - private func updateShowVariables() { - DispatchQueue.main.async { - self.showIAP = self.shouldShowIAP - self.showIAPWarning = self.shouldShowIAPWarning - } - } - - private func setUpdateTimer() { - if !self.showIAPWarning { - if let expireOnTimer = expireOnTimer { - expireOnTimer.invalidate() - } + /// Check subscription status - call on app launch and when becoming active + func checkSubscriptionStatus() async { + isLoading = true + defer { isLoading = false } + + // Fetch available products + await loadProducts() + + // Check for active subscription + let hasActiveSubscription = await checkForActiveSubscription() + + if hasActiveSubscription { + // State already set in checkForActiveSubscription + syncSubscriptionStatusToUserDefaults() return } - if let expireDate = expireDate { - expireOnTimer = Timer.init(fire: expireDate, interval: 0, repeats: false, block: { _ in - self.updateShowVariables() - }) - RunLoop.main.add(expireOnTimer!, forMode: .common) - } else { - if let expireOnTimer = expireOnTimer { - expireOnTimer.invalidate() - } - } + + // No active subscription - check trial status + updateTrialState() } - - func listenForTransactions() -> Task { - return Task.detached { - //Iterate through any transactions that don't come from a direct call to `purchase()`. - for await result in Transaction.updates { - do { - let transaction = try self.checkVerified(result) - - //Deliver products to the user. - await self.updateCustomerProductStatus() - - self.updateShowVariables() - - //Always finish a transaction. - await transaction.finish() - } catch { - //StoreKit has a transaction that fails verification. Don't deliver content to the user. - print("Transaction failed verification") - } - } - } + + /// Sync subscription status to UserDefaults for widget access + private func syncSubscriptionStatusToUserDefaults() { + GroupUserDefaults.groupDefaults.set(hasFullAccess, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue) } - - // fetch all available iap from remote and store locally - // in subscriptions - @MainActor - func requestProducts() async { - do { - subscriptions.removeAll() - - //Request products from the App Store using the identifiers that the Products.plist file defines. - let storeProducts = try await Product.products(for: iapIdentifiers) - - //Filter the products into categories based on their type. - for product in storeProducts { - switch product.type { - case .consumable: - break - case .nonConsumable: - break - case .autoRenewable: - subscriptions.updateValue(nil, forKey: product) - case .nonRenewable: - break - default: - //Ignore this product. - print("Unknown product") - } - } - - } catch { - print("Failed product request from the App Store server: \(error)") - } - } - - // quickly check current entitlments if we have a sub - private func updatePurchasedProducts() async { - for await result in Transaction.currentEntitlements { - guard case .verified(let transaction) = result else { - continue - } - - if transaction.revocationDate == nil { - self.purchasedProductIDs.insert(transaction.productID) - } else { - self.purchasedProductIDs.remove(transaction.productID) - } - } - } - - // fetch all subscriptions and fill out subscriptions with current - // status of each - @MainActor - func updateCustomerProductStatus() async { - var purchasedSubscriptions: [Product] = [] - - // Iterate through all of the user's purchased products. - for await result in Transaction.currentEntitlements { - do { - //Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. - let transaction = try checkVerified(result) - - //Check the `productType` of the transaction and get the corresponding product from the store. - switch transaction.productType { - case .nonConsumable: - break - case .nonRenewable: - break - case .autoRenewable: - if let subscription = subscriptions.first(where: { - $0.key.id == transaction.productID - }) { - purchasedSubscriptions.append(subscription.key) - } - default: - break - } - } catch { - print() - } - } - - for sub in purchasedSubscriptions { - guard let statuses = try? await sub.subscription?.status else { - return - } - - for status in statuses { - switch status.state { - case .expired, .revoked: - continue - default: - if let renewalInfo = try? checkVerified(status.renewalInfo) { - subscriptions.updateValue((statuses, renewalInfo), forKey: sub) - } - } - } - } - } - - func purchase(_ product: Product) async throws -> Transaction? { - DispatchQueue.main.async { - self.isPurchasing = true - } - - //Begin purchasing the `Product` the user selects. - let result = try await product.purchase() - - switch result { - case .success(let verification): - //Check whether the transaction is verified. If it isn't, - //this function rethrows the verification error. - let transaction = try checkVerified(verification) - - //The transaction is verified. Deliver content to the user. - await updateCustomerProductStatus() - - self.updateShowVariables() - - //Always finish a transaction. - await transaction.finish() - - DispatchQueue.main.async { - self.isPurchasing = false - } - - return transaction - case .userCancelled, .pending: - return nil - default: - return nil - } - } - - func isPurchased(_ product: Product) async throws -> Bool { - //Determine whether the user purchases a given product. - switch product.type { - case .nonRenewable: - return false - case .nonConsumable: - return false - case .autoRenewable: - return subscriptions.keys.contains(product) - default: - return false - } - } - - public func restore() async { + + /// Restore purchases + func restore() async { do { try await AppStore.sync() + await checkSubscriptionStatus() } catch { - print(error) + print("Failed to restore purchases: \(error)") } } - - func checkVerified(_ result: VerificationResult) throws -> T { - //Check whether the JWS passes StoreKit verification. - switch result { - case .unverified: - //StoreKit parses the JWS, but it fails verification. - throw StoreError.failedVerification - case .verified(let safe): - //The result is verified. Return the unwrapped value. - return safe + + // MARK: - Private Methods + + private func loadProducts() async { + do { + let products = try await Product.products(for: productIdentifiers) + availableProducts = products.filter { $0.type == .autoRenewable } + } catch { + print("Failed to load products: \(error)") } } - - func sortByPrice(_ products: [Product]) -> [Product] { - products.sorted(by: { return $0.price < $1.price }) + + private func checkForActiveSubscription() async -> Bool { + var foundActiveSubscription = false + + for await result in Transaction.currentEntitlements { + guard case .verified(let transaction) = result else { continue } + + // Skip revoked transactions + if transaction.revocationDate != nil { continue } + + // Check if this is one of our subscription products + guard productIdentifiers.contains(transaction.productID) else { continue } + + // Found an active subscription + foundActiveSubscription = true + + // Get the product for this transaction + currentProduct = availableProducts.first { $0.id == transaction.productID } + + // Get renewal info + if let product = currentProduct, + let subscription = product.subscription, + let statuses = try? await subscription.status { + + for status in statuses { + guard case .verified(let renewalInfo) = status.renewalInfo else { continue } + + switch status.state { + case .subscribed, .inGracePeriod, .inBillingRetryPeriod: + state = .subscribed( + expirationDate: transaction.expirationDate, + willAutoRenew: renewalInfo.willAutoRenew + ) + return true + case .expired, .revoked: + continue + default: + continue + } + } + } + + // Fallback if we couldn't get detailed status + state = .subscribed(expirationDate: transaction.expirationDate, willAutoRenew: false) + return true + } + + // No active subscription found + currentProduct = nil + return false } - - func colorForIAPButton(iapIdentifier: String) -> Color { - if iapIdentifier == "com.88oakapps.ifeel.IAP.subscriptions.weekly" { - return DefaultMoodTint.color(forMood: .horrible) + + private func updateTrialState() { + let daysSinceInstall = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0 + let daysRemaining = trialDays - daysSinceInstall + + if daysRemaining > 0 { + state = .inTrial(daysRemaining: daysRemaining) + } else { + state = .trialExpired } - else if iapIdentifier == "com.88oakapps.ifeel.IAP.subscriptions.monthly" { - return DefaultMoodTint.color(forMood: .average) + + syncSubscriptionStatusToUserDefaults() + } + + private func listenForTransactions() -> Task { + Task.detached { [weak self] in + for await result in Transaction.updates { + guard case .verified(let transaction) = result else { continue } + + await transaction.finish() + await self?.checkSubscriptionStatus() + } } - else if iapIdentifier == "com.88oakapps.ifeel.IAP.subscriptions.yearly" { - return DefaultMoodTint.color(forMood: .great) - } - - return .blue } } diff --git a/Shared/Models/MoodEntryExtension.swift b/Shared/Models/MoodEntryExtension.swift index bb9f55d..584acfd 100644 --- a/Shared/Models/MoodEntryExtension.swift +++ b/Shared/Models/MoodEntryExtension.swift @@ -12,6 +12,7 @@ enum EntryType: Int { case header case listView case filledInMissing + case widget } extension MoodEntry { diff --git a/Shared/Models/UserDefaultsStore.swift b/Shared/Models/UserDefaultsStore.swift index c419e3b..030b059 100644 --- a/Shared/Models/UserDefaultsStore.swift +++ b/Shared/Models/UserDefaultsStore.swift @@ -26,7 +26,9 @@ class UserDefaultsStore { case shape case daysFilter case firstLaunchDate - + case hasActiveSubscription + case lastVotedDate + case contentViewCurrentSelectedHeaderViewBackDays case contentViewHeaderTag case contentViewHeaderTagViewOneViewType diff --git a/Shared/Persisence/Persistence.swift b/Shared/Persisence/Persistence.swift index e178bf2..32fe751 100644 --- a/Shared/Persisence/Persistence.swift +++ b/Shared/Persisence/Persistence.swift @@ -9,12 +9,14 @@ import CoreData import SwiftUI class PersistenceController { - @AppStorage(UserDefaultsStore.Keys.useCloudKit.rawValue, store: GroupUserDefaults.groupDefaults) private var useCloudKit = false + @AppStorage(UserDefaultsStore.Keys.useCloudKit.rawValue, store: GroupUserDefaults.groupDefaults) + + private var useCloudKit = false static let shared = PersistenceController.persistenceController private static var persistenceController: PersistenceController { - return PersistenceController(inMemory: false) + return PersistenceController(inMemory: true) } public var viewContext: NSManagedObjectContext { @@ -117,12 +119,19 @@ extension NSManagedObjectContext { class NSCustomPersistentContainer: NSPersistentContainer { override open class func defaultDirectoryURL() -> URL { #if DEBUG - var storeURLDebug = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.groupShareIdDebug) - storeURLDebug = storeURLDebug?.appendingPathComponent("Feels-Debug.sqlite") - return storeURLDebug! + if let storeURLDebug = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.groupShareIdDebug) { + return storeURLDebug.appendingPathComponent("Feels-Debug.sqlite") + } + // Fallback to default location if App Group not available + print("⚠️ App Group not available, using default Core Data location") + return super.defaultDirectoryURL() +#else + if let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.groupShareId) { + return storeURL.appendingPathComponent("Feels.sqlite") + } + // Fallback to default location if App Group not available + print("⚠️ App Group not available, using default Core Data location") + return super.defaultDirectoryURL() #endif - var storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.groupShareId) - storeURL = storeURL?.appendingPathComponent("Feels.sqlite") - return storeURL! } } diff --git a/Shared/Random.swift b/Shared/Random.swift index 08a9d15..55669cf 100644 --- a/Shared/Random.swift +++ b/Shared/Random.swift @@ -9,8 +9,8 @@ import Foundation import SwiftUI struct Constants { - static let groupShareId = "group.com.88oakapps.ifeel" - static let groupShareIdDebug = "group.com.88oakapps.ifeelDebug" + static let groupShareId = "group.com.tt.ifeel" + static let groupShareIdDebug = "group.com.tt.ifeelDebug" static let viewsCornerRaidus: CGFloat = 10 } diff --git a/Shared/views/FeelsSubscriptionStoreView.swift b/Shared/views/FeelsSubscriptionStoreView.swift new file mode 100644 index 0000000..447d876 --- /dev/null +++ b/Shared/views/FeelsSubscriptionStoreView.swift @@ -0,0 +1,47 @@ +// +// FeelsSubscriptionStoreView.swift +// Feels +// +// Native StoreKit 2 subscription purchase view. +// + +import SwiftUI +import StoreKit + +struct FeelsSubscriptionStoreView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var iapManager: IAPManager + + var body: some View { + SubscriptionStoreView(groupID: IAPManager.subscriptionGroupID) { + VStack(spacing: 16) { + Image(systemName: "heart.fill") + .font(.system(size: 60)) + .foregroundStyle(.pink) + + Text(String(localized: "subscription_store_title")) + .font(.title) + .bold() + + Text(String(localized: "subscription_store_subtitle")) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } + .subscriptionStoreControlStyle(.prominentPicker) + .storeButton(.visible, for: .restorePurchases) + .subscriptionStoreButtonLabel(.multiline) + .onInAppPurchaseCompletion { _, result in + if case .success(.success(_)) = result { + dismiss() + } + } + } +} + +#Preview { + FeelsSubscriptionStoreView() + .environmentObject(IAPManager()) +} diff --git a/Shared/views/IAPWarningView.swift b/Shared/views/IAPWarningView.swift index 20e090e..7b8da69 100644 --- a/Shared/views/IAPWarningView.swift +++ b/Shared/views/IAPWarningView.swift @@ -1,69 +1,57 @@ // -// PurchaseButtonView.swift +// IAPWarningView.swift // Feels // -// Created by Trey Tartt on 7/7/22. +// Trial warning banner shown at bottom of Month/Year views. // import SwiftUI -import StoreKit struct IAPWarningView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor - @AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date() - - var iapManager: IAPManager - - private let height: Float - private let showManageSubClosure: (() -> Void)? - - @State private var showSettings = false - - public init(height: Float, iapManager: IAPManager, showManageSubClosure: (() -> Void)? = nil, showCountdownTimer: Bool = false) { - self.height = height - self.showManageSubClosure = showManageSubClosure - self.iapManager = iapManager - } - + + @ObservedObject var iapManager: IAPManager + + @State private var showSubscriptionStore = false + var body: some View { - VStack { - if let date = Calendar.current.date(byAdding: .day, value: 30, to: firstLaunchDate) { + VStack(spacing: 8) { + HStack { + Image(systemName: "clock") + .foregroundColor(.orange) + Text(String(localized: "iap_warning_view_title")) .font(.body) - .frame(minWidth: 0, maxWidth: .infinity) - .background(theme.currentTheme.secondaryBGColor) - - Text(date, style: .relative) - .font(.body) - .bold() .foregroundColor(textColor) - - Button(action: { - showSettings.toggle() - }, label: { - Text(String(localized: "iap_warning_view_buy_button")) - .foregroundColor(.white) + + if let expirationDate = iapManager.trialExpirationDate { + Text(expirationDate, style: .relative) + .font(.body) .bold() - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - }) - .frame(maxWidth: .infinity) - .frame(height: 50) - .background(RoundedRectangle(cornerRadius: 10).fill(DefaultMoodTint.color(forMood: .great))) + .foregroundColor(.orange) + } + } + + Button { + showSubscriptionStore = true + } label: { + Text(String(localized: "iap_warning_view_buy_button")) + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color.pink)) } } - .frame(minWidth: 0, maxWidth: .infinity) .padding() .background(theme.currentTheme.secondaryBGColor) - .sheet(isPresented: $showSettings) { - SettingsView() + .sheet(isPresented: $showSubscriptionStore) { + FeelsSubscriptionStoreView() } } } -struct IAPWarningView_Previews: PreviewProvider { - static var previews: some View { - IAPWarningView(height: 175, iapManager: IAPManager()) - } +#Preview { + IAPWarningView(iapManager: IAPManager()) } diff --git a/Shared/views/MonthView/MonthView.swift b/Shared/views/MonthView/MonthView.swift index 53f01ec..c367d25 100644 --- a/Shared/views/MonthView/MonthView.swift +++ b/Shared/views/MonthView/MonthView.swift @@ -44,8 +44,9 @@ struct MonthView: View { ] @ObservedObject var viewModel: DayViewViewModel - @State private var iAPWarningViewHidden = false - + @State private var trialWarningHidden = false + @State private var showSubscriptionStore = false + var body: some View { ZStack { if viewModel.hasNoData { @@ -55,7 +56,7 @@ struct MonthView: View { ScrollView { VStack(spacing: 5) { ForEach(viewModel.grouped.sorted(by: { $0.key < $1.key }), id: \.key) { year, months in - + // for reach month ForEach(months.sorted(by: { $0.key < $1.key }), id: \.key) { month, entries in Section() { @@ -80,23 +81,43 @@ struct MonthView: View { } ) } - .disabled(iapManager.showIAP) + .disabled(iapManager.shouldShowPaywall) } - - if iapManager.showIAP { + + if iapManager.shouldShowPaywall { + // Paywall overlay - tap to show subscription store + Color.black.opacity(0.3) + .ignoresSafeArea() + .onTapGesture { + showSubscriptionStore = true + } + VStack { Spacer() - PurchaseButtonView(height: 250, iapManager: iapManager) + Button { + showSubscriptionStore = true + } label: { + Text(String(localized: "subscription_required_button")) + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(RoundedRectangle(cornerRadius: 10).fill(Color.pink)) + } + .padding() } - } else if iapManager.showIAPWarning { + } else if iapManager.shouldShowTrialWarning { VStack { Spacer() - if !iAPWarningViewHidden { - IAPWarningView(height: 75, iapManager: iapManager) + if !trialWarningHidden { + IAPWarningView(iapManager: iapManager) } } } } + .sheet(isPresented: $showSubscriptionStore) { + FeelsSubscriptionStoreView() + } .onAppear(perform: { EventLogger.log(event: "show_month_view") }) @@ -116,7 +137,7 @@ struct MonthView: View { } .onPreferenceChange(ViewOffsetKey.self) { value in withAnimation { - iAPWarningViewHidden = value < 0 + trialWarningHidden = value < 0 } } } diff --git a/Shared/views/PurchaseButtonView.swift b/Shared/views/PurchaseButtonView.swift index 7d1e16c..e093b91 100644 --- a/Shared/views/PurchaseButtonView.swift +++ b/Shared/views/PurchaseButtonView.swift @@ -2,7 +2,7 @@ // PurchaseButtonView.swift // Feels // -// Created by Trey Tartt on 7/7/22. +// Subscription status and purchase view for settings. // import SwiftUI @@ -11,238 +11,190 @@ import StoreKit struct PurchaseButtonView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor - @AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date() - - var iapManager: IAPManager - - private let showCountdownTimer: Bool - private let showManageSubClosure: (() -> Void)? - private let height: CGFloat? - - public init(height: CGFloat? = nil, - iapManager: IAPManager, - showManageSubClosure: (() -> Void)? = nil, - showCountdownTimer: Bool = false) { - self.height = height - self.showManageSubClosure = showManageSubClosure - self.iapManager = iapManager - self.showCountdownTimer = showCountdownTimer - } - + + @ObservedObject var iapManager: IAPManager + + @State private var showSubscriptionStore = false + @State private var showManageSubscriptions = false + var body: some View { - ZStack { - // if we should show the iap warning that means no purchase which means - // we should show buy options - switch iapManager.showIAPWarning { - case true: - VStack { - if let height = self.height { - buyOptionsView - .background(theme.currentTheme.secondaryBGColor) - .frame(height: height) - } else { - buyOptionsView - .background(theme.currentTheme.secondaryBGColor) - } - } - case false: + VStack(spacing: 16) { + if iapManager.isLoading { + loadingView + } else if iapManager.isSubscribed { subscribedView - .background(theme.currentTheme.secondaryBGColor) + } else { + notSubscribedView } } - } - - private var buyOptionsSetingsView: some View { - GeometryReader { metrics in - VStack(spacing: 20) { - Text(String(localized: "purchase_view_title")) - .foregroundColor(textColor) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - .padding([.leading, .trailing]) - VStack(alignment: .leading) { - ForEach(iapManager.sortedSubscriptionKeysByPriceOptions, id: \.self) { product in - HStack { - Button(action: { - purchase(product: product) - }, label: { - Text("\(product.displayPrice)\n\(product.displayName)") - .foregroundColor(.white) - .bold() - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - }) - .frame(maxWidth: .infinity) - .frame(height: 50) - .padding() - .background(RoundedRectangle(cornerRadius: 10).fill(iapManager.colorForIAPButton(iapIdentifier: product.id))) - } - } - } - .padding([.leading, .leading]) - } + .padding() + .background(theme.currentTheme.secondaryBGColor) + .cornerRadius(10) + .sheet(isPresented: $showSubscriptionStore) { + FeelsSubscriptionStoreView() } + .manageSubscriptionsSheet(isPresented: $showManageSubscriptions) } - - private var buyOptionsView: some View { - VStack { - ZStack { - theme.currentTheme.secondaryBGColor - - if iapManager.isLoadingSubscriptions { - VStack(spacing: 20) { - Text(String(localized: "purchase_view_loading")) - .font(.body) - .bold() - .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) - ProgressView() - } - } else { - - VStack(spacing: 20) { - Text(String(localized: "purchase_view_title")) - .font(.body) - .bold() - .foregroundColor(textColor) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - .padding(.top) - - if showCountdownTimer { - if let date = Calendar.current.date(byAdding: .day, value: 30, to: firstLaunchDate) { - HStack { - if iapManager.daysLeftBeforeIAP > 0 { - Text(String(localized: "purchase_view_current_subscription_expires_in")) - .font(.body) - .bold() - .foregroundColor(textColor) - - Text(date, style: .relative) - .font(.body) - .bold() - .foregroundColor(textColor) - } else { - Text(String(localized: "purchase_view_current_subscription_expired_on")) - .font(.body) - .bold() - .foregroundColor(textColor) - - Text(date, style: .date) - .font(.body) - .bold() - .foregroundColor(textColor) - } - } - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - } - } - - Text(String(localized: "purchase_view_current_why_subscribe")) - .font(.body) - .bold() - .foregroundColor(textColor) - - HStack { - ForEach(iapManager.sortedSubscriptionKeysByPriceOptions) { product in - Button(action: { - purchase(product: product) - }, label: { - Text("\(product.displayPrice)\n\(product.displayName)") - .foregroundColor(.white) - .bold() - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - .frame(height: 65) - }) - .padding() - .frame(maxWidth: .infinity) - .background(RoundedRectangle(cornerRadius: 10).fill(iapManager.colorForIAPButton(iapIdentifier: product.id))) - } - } - } - .padding([.leading, .trailing]) - .frame(minWidth: 0, maxWidth: .infinity) - - } - } + + // MARK: - Loading View + + private var loadingView: some View { + VStack(spacing: 12) { + ProgressView() + Text(String(localized: "purchase_view_loading")) + .font(.body) + .foregroundColor(textColor) } - .background(.ultraThinMaterial) - .frame(minWidth: 0, maxWidth: .infinity) - .background(.clear) + .frame(maxWidth: .infinity) + .padding() } + // MARK: - Subscribed View + private var subscribedView: some View { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 12) { Text(String(localized: "purchase_view_current_subscription")) .font(.title3) - .padding([.leading, .top]) - - Divider() - - if let currentProduct = iapManager.currentSubscription, - let value = iapManager.subscriptions[currentProduct] { + .bold() + .foregroundColor(textColor) + + if let product = iapManager.currentProduct { HStack { - VStack (alignment: .leading, spacing: 10) { - Text(currentProduct.displayName) - .font(.title3) - Text(currentProduct.displayPrice) - .font(.title3) - }.padding([.leading, .trailing]) - - ForEach(value!.status, id: \.self) { singleStatus in - StatusInfoView(product: currentProduct, status: singleStatus) - .padding([.leading]) - .font(.body) + VStack(alignment: .leading, spacing: 4) { + Text(product.displayName) + .font(.headline) + .foregroundColor(textColor) + Text(product.displayPrice) + .font(.subheadline) + .foregroundColor(.secondary) } + + Spacer() + + subscriptionStatusBadge } } - - Button(action: { - showManageSubClosure?() - }, label: { - Text(String(localized: "purchase_view_cancel")) - .foregroundColor(.red) - }) - .frame(maxWidth: .infinity) - .padding([.bottom]) - .multilineTextAlignment(.center) - + Divider() - - showOtherSubOptions - } - } - - private var showOtherSubOptions: some View { - VStack (spacing: 10) { - HStack { - ForEach(iapManager.sortedSubscriptionKeysByPriceOptions, id: \.self) { product in - if product.id != iapManager.nextRenewllOption?.id { - Button(action: { - purchase(product: product) - }, label: { - Text("\(product.displayPrice)\n\(product.displayName)") - .foregroundColor(.white) - .font(.headline) - }) - .contentShape(Rectangle()) - .padding() + + // Manage subscription button + Button { + showManageSubscriptions = true + } label: { + Text(String(localized: "purchase_view_manage_subscription")) + .font(.body) + .foregroundColor(.blue) + .frame(maxWidth: .infinity) + } + + // Show other subscription options + if iapManager.sortedProducts.count > 1 { + Button { + showSubscriptionStore = true + } label: { + Text(String(localized: "purchase_view_change_plan")) + .font(.body) + .foregroundColor(.secondary) .frame(maxWidth: .infinity) - .background(RoundedRectangle(cornerRadius: 10).fill(iapManager.colorForIAPButton(iapIdentifier: product.id))) - } } } } - .padding([.leading, .trailing]) } - - private func purchase(product: Product) { - Task { - try await iapManager.purchase(product) + + private var subscriptionStatusBadge: some View { + Group { + if case .subscribed(_, let willAutoRenew) = iapManager.state { + if willAutoRenew { + Text(String(localized: "subscription_status_active")) + .font(.caption) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.green) + .cornerRadius(4) + } else { + Text(String(localized: "subscription_status_expires")) + .font(.caption) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.orange) + .cornerRadius(4) + } + } } } + + // MARK: - Not Subscribed View + + private var notSubscribedView: some View { + VStack(spacing: 16) { + Text(String(localized: "purchase_view_title")) + .font(.title3) + .bold() + .foregroundColor(textColor) + .frame(maxWidth: .infinity, alignment: .leading) + + // Trial status + if iapManager.shouldShowTrialWarning { + trialStatusView + } else if iapManager.shouldShowPaywall { + Text(String(localized: "purchase_view_trial_expired")) + .font(.body) + .foregroundColor(.secondary) + } + + Text(String(localized: "purchase_view_current_why_subscribe")) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + // Subscribe button + Button { + showSubscriptionStore = true + } label: { + Text(String(localized: "purchase_view_subscribe_button")) + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color.pink) + .cornerRadius(10) + } + + // Restore purchases + Button { + Task { + await iapManager.restore() + } + } label: { + Text(String(localized: "purchase_view_restore")) + .font(.body) + .foregroundColor(.blue) + } + } + } + + private var trialStatusView: some View { + HStack { + Image(systemName: "clock") + .foregroundColor(.orange) + + if let expirationDate = iapManager.trialExpirationDate { + Text(String(localized: "purchase_view_trial_expires_in")) + .foregroundColor(textColor) + + + Text(" ") + + + Text(expirationDate, style: .relative) + .foregroundColor(.orange) + .bold() + } + } + .font(.body) + } } -struct PurchaseButtonView_Previews: PreviewProvider { - static var previews: some View { - PurchaseButtonView(iapManager: IAPManager()) - } +#Preview { + PurchaseButtonView(iapManager: IAPManager()) } diff --git a/Shared/views/SettingsView/SettingsView.swift b/Shared/views/SettingsView/SettingsView.swift index adbdbb4..754f30c 100644 --- a/Shared/views/SettingsView/SettingsView.swift +++ b/Shared/views/SettingsView/SettingsView.swift @@ -147,30 +147,7 @@ struct SettingsView: View { } private var subscriptionInfoView: some View { - ZStack { - theme.currentTheme.secondaryBGColor - VStack { - PurchaseButtonView(iapManager: iapManager, showManageSubClosure: { - Task { - await - self.showManageSubscription() - } - }, showCountdownTimer: true) - - if iapManager.showIAPWarning { - Button(action: { - Task { - await iapManager.restore() - } - }, label: { - Text(String(localized: "purchase_view_restore")) - .font(.title3) - .padding([.top, .bottom]) - }) - } - } - } - .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) + PurchaseButtonView(iapManager: iapManager) } private var closeButtonView: some View { @@ -245,7 +222,9 @@ struct SettingsView: View { tmpDate = Calendar.current.date(byAdding: .minute, value: -59, to: tmpDate)! tmpDate = Calendar.current.date(byAdding: .second, value: -45, to: tmpDate)! firstLaunchDate = tmpDate - iapManager.updateEverything() + Task { + await iapManager.checkSubscriptionStatus() + } }, label: { Text("Set first launch date back 29 days, 23 hrs, 45 seconds") .foregroundColor(textColor) @@ -255,13 +234,15 @@ struct SettingsView: View { .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } - + private var resetLaunchDate: some View { ZStack { theme.currentTheme.secondaryBGColor Button(action: { firstLaunchDate = Date() - iapManager.updateEverything() + Task { + await iapManager.checkSubscriptionStatus() + } }, label: { Text("Reset luanch date to current date") .foregroundColor(textColor) @@ -590,16 +571,6 @@ struct SettingsView: View { .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } - func showManageSubscription() async { - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene{ - do { - try await StoreKit.AppStore.showManageSubscriptions(in: windowScene) - iapManager.updateEverything() - } catch { - print("Sheet can not be opened") - } - } - } } struct TextFile: FileDocument { diff --git a/Shared/views/StatusInfoView.swift b/Shared/views/StatusInfoView.swift deleted file mode 100644 index 983448d..0000000 --- a/Shared/views/StatusInfoView.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// StatusInfoView.swift -// Feels -// -// Created by Trey Tartt on 7/8/22. -// - -import SwiftUI -import StoreKit - -struct StatusInfoView: View { - @EnvironmentObject var iapManager: IAPManager - - let product: Product - let status: Product.SubscriptionInfo.Status - - var body: some View { - ScrollView { - Text(statusDescription()) - .frame(maxWidth: .infinity, alignment: .center) - .font(.body) - } - .frame(maxWidth: .infinity) - .frame(height: 75) - } - - //Build a string description of the subscription status to display to the user. - fileprivate func statusDescription() -> String { - guard case .verified(let renewalInfo) = status.renewalInfo, - case .verified(let transaction) = status.transaction else { - return "The App Store could not verify your subscription status." - } - - var description = "" - - switch status.state { - case .subscribed: - let renewToProduct: Product? - let renewalInfo: RenewalInfo? - - renewalInfo = try? iapManager.checkVerified(status.renewalInfo) - renewToProduct = iapManager.subscriptions.first(where: { - $0.key.id == renewalInfo?.autoRenewPreference - })?.key - - description = subscribedDescription(expirationDate: transaction.expirationDate, - willRenew: renewalInfo?.willAutoRenew ?? false, - willRenewTo: renewToProduct) - case .expired: - if let expirationDate = transaction.expirationDate, - let expirationReason = renewalInfo.expirationReason { - description = expirationDescription(expirationReason, expirationDate: expirationDate) - } - case .revoked: - if let revokedDate = transaction.revocationDate { - description = "The App Store refunded your subscription to \(product.displayName) on \(revokedDate.formattedDate())." - } - case .inGracePeriod: - description = gracePeriodDescription(renewalInfo) - case .inBillingRetryPeriod: - description = billingRetryDescription() - default: - break - } - return description - } - - fileprivate func billingRetryDescription() -> String { - var description = "The App Store could not confirm your billing information for \(product.displayName)." - description += " Please verify your billing information to resume service." - return description - } - - fileprivate func gracePeriodDescription(_ renewalInfo: RenewalInfo) -> String { - var description = "The App Store could not confirm your billing information for \(product.displayName)." - if let untilDate = renewalInfo.gracePeriodExpirationDate { - description += " Please verify your billing information to continue service after \(untilDate.formattedDate())" - } - - return description - } - - fileprivate func subscribedDescription(expirationDate: Date?, willRenew: Bool, willRenewTo: Product?) -> String { - var description = "You are currently subscribed to \(product.displayName)" - - if let expirationDate = expirationDate { - description += ", which will expire on \(expirationDate.formattedDate())," - } - - if willRenew { - if let willRenewTo = willRenewTo { - if willRenewTo == product { - description += " and will auto renew." - } else { - description += " and will auto renew to \(willRenewTo.displayName) at \(willRenewTo.displayPrice)." - } - } - } else { - description += " and will NOT auto renew." - } - - return description - } - - fileprivate func renewalDescription(_ renewalInfo: RenewalInfo, _ expirationDate: Date) -> String { - var description = "" - - if let newProductID = renewalInfo.autoRenewPreference { - if let newProduct = iapManager.subscriptions.first(where: { $0.key.id == newProductID }) { - description += "\nYour subscription to \(newProduct.key.displayName)" - description += " will begin when your current subscription expires on \(expirationDate.formattedDate())." - } - } else if renewalInfo.willAutoRenew { - description += "\nWill auto renew on: \(expirationDate.formattedDate())." - } - - return description - } - - //Build a string description of the `expirationReason` to display to the user. - fileprivate func expirationDescription(_ expirationReason: RenewalInfo.ExpirationReason, expirationDate: Date) -> String { - var description = "" - - switch expirationReason { - case .autoRenewDisabled: - if expirationDate > Date() { - description += "Your subscription to \(product.displayName) will expire on \(expirationDate.formattedDate())." - } else { - description += "Your subscription to \(product.displayName) expired on \(expirationDate.formattedDate())." - } - case .billingError: - description = "Your subscription to \(product.displayName) was not renewed due to a billing error." - case .didNotConsentToPriceIncrease: - description = "Your subscription to \(product.displayName) was not renewed due to a price increase that you disapproved." - case .productUnavailable: - description = "Your subscription to \(product.displayName) was not renewed because the product is no longer available." - default: - description = "Your subscription to \(product.displayName) was not renewed." - } - - return description - } -} diff --git a/Shared/views/YearView/YearView.swift b/Shared/views/YearView/YearView.swift index f9b11e3..cb28725 100644 --- a/Shared/views/YearView/YearView.swift +++ b/Shared/views/YearView/YearView.swift @@ -25,7 +25,8 @@ struct YearView: View { @EnvironmentObject var iapManager: IAPManager @StateObject public var viewModel: YearViewModel @StateObject private var filteredDays = DaysFilterClass.shared - @State private var iAPWarningViewHidden = false + @State private var trialWarningHidden = false + @State private var showSubscriptionStore = false //[ // 2001: [0: [], 1: [], 2: []], // 2002: [0: [], 1: [], 2: []] @@ -60,24 +61,44 @@ struct YearView: View { } ) } - .disabled(iapManager.showIAP) + .disabled(iapManager.shouldShowPaywall) .padding(.bottom, 5) } - - if iapManager.showIAP { + + if iapManager.shouldShowPaywall { + // Paywall overlay - tap to show subscription store + Color.black.opacity(0.3) + .ignoresSafeArea() + .onTapGesture { + showSubscriptionStore = true + } + VStack { Spacer() - PurchaseButtonView(height: 250, iapManager: iapManager) + Button { + showSubscriptionStore = true + } label: { + Text(String(localized: "subscription_required_button")) + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(RoundedRectangle(cornerRadius: 10).fill(Color.pink)) + } + .padding() } - } else if iapManager.showIAPWarning { + } else if iapManager.shouldShowTrialWarning { VStack { Spacer() - if !iAPWarningViewHidden { - IAPWarningView(height: 75, iapManager: iapManager) + if !trialWarningHidden { + IAPWarningView(iapManager: iapManager) } } } } + .sheet(isPresented: $showSubscriptionStore) { + FeelsSubscriptionStoreView() + } .onAppear(perform: { self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date()) }) @@ -87,7 +108,7 @@ struct YearView: View { ) .onPreferenceChange(ViewOffsetKey.self) { value in withAnimation { - iAPWarningViewHidden = value < 0 + trialWarningHidden = value < 0 } } .padding([.top]) diff --git a/en.lproj/Localizable.strings b/en.lproj/Localizable.strings index 5ee6dd9..6c2266b 100644 --- a/en.lproj/Localizable.strings +++ b/en.lproj/Localizable.strings @@ -120,9 +120,20 @@ "purchase_view_loading" = "Loading subscription options"; "purchase_view_restore" = "Restore"; -"iap_warning_view_title" = "This view will no longer scroll in "; +"iap_warning_view_title" = "Trial expires in "; "iap_warning_view_buy_button" = "Subscribe Now"; +"subscription_store_title" = "Unlock Full Access"; +"subscription_store_subtitle" = "Get unlimited access to Month and Year views, plus all your historical mood data."; +"subscription_required_button" = "Subscribe to Unlock"; +"subscription_status_active" = "Active"; +"subscription_status_expires" = "Expires Soon"; +"purchase_view_trial_expired" = "Your free trial has ended."; +"purchase_view_subscribe_button" = "Subscribe Now"; +"purchase_view_trial_expires_in" = "Trial expires in"; +"purchase_view_manage_subscription" = "Manage Subscription"; +"purchase_view_change_plan" = "Change Plan"; + /* not used */ diff --git a/es.lproj/Localizable.strings b/es.lproj/Localizable.strings index c317479..debc016 100644 --- a/es.lproj/Localizable.strings +++ b/es.lproj/Localizable.strings @@ -120,8 +120,19 @@ "purchase_view_loading" = "Loading subscription options"; "purchase_view_restore" = "Restore"; -"iap_warning_view_title" = "This view will no longer scroll in "; -"iap_warning_view_buy_button" = "Subscribe Now"; +"iap_warning_view_title" = "La prueba expira en "; +"iap_warning_view_buy_button" = "Suscribirse Ahora"; + +"subscription_store_title" = "Desbloquear Acceso Completo"; +"subscription_store_subtitle" = "Obtén acceso ilimitado a las vistas de Mes y Año, además de todos tus datos de estado de ánimo históricos."; +"subscription_required_button" = "Suscribirse para Desbloquear"; +"subscription_status_active" = "Activa"; +"subscription_status_expires" = "Expira Pronto"; +"purchase_view_trial_expired" = "Tu prueba gratuita ha terminado."; +"purchase_view_subscribe_button" = "Suscribirse Ahora"; +"purchase_view_trial_expires_in" = "La prueba expira en"; +"purchase_view_manage_subscription" = "Administrar Suscripción"; +"purchase_view_change_plan" = "Cambiar Plan"; diff --git a/macOS/macOS.entitlements b/macOS/macOS.entitlements index f2ef3ae..0c67376 100644 --- a/macOS/macOS.entitlements +++ b/macOS/macOS.entitlements @@ -1,10 +1,5 @@ - - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - - +