Fix widget issues and add subscription bypass toggle

Widget fixes:
- Fix App Group ID mismatch in iOS app entitlements (was group.com.tt.ifeel.ifeelDebug, now group.com.tt.ifeelDebug)
- Fix date bug where missing entries all showed same date
- Add sample data preview for widget picker (shows realistic mood data)
- Add widgetDisplayName to Mood enum for widget localization
- Update Mood Vote widget preview to show post-vote state
- Attempt to fix interactive widget buttons (openAppWhenRun: false)

Developer improvements:
- Add IAPManager.bypassSubscription toggle for testing without subscription

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-10 10:38:16 -06:00
parent 84c0e191b1
commit 443f4dfc55
6 changed files with 123 additions and 38 deletions

View File

@@ -14,7 +14,7 @@
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.tt.ifeel.ifeelDebug</string>
<string>group.com.tt.ifeelDebug</string>
</array>
</dict>
</plist>

View File

@@ -14,6 +14,7 @@ import AppIntents
struct VoteMoodIntent: AppIntent {
static var title: LocalizedStringResource = "Vote Mood"
static var description = IntentDescription("Record your mood for today")
static var openAppWhenRun: Bool { false }
@Parameter(title: "Mood")
var moodValue: Int
@@ -26,6 +27,7 @@ struct VoteMoodIntent: AppIntent {
self.moodValue = mood.rawValue
}
@MainActor
func perform() async throws -> some IntentResult {
let mood = Mood(rawValue: moodValue) ?? .average
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
@@ -48,10 +50,19 @@ struct VoteMoodIntent: AppIntent {
struct VoteWidgetProvider: TimelineProvider {
func placeholder(in context: Context) -> VoteWidgetEntry {
VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: false, todaysMood: nil, stats: nil)
// Show sample "already voted" state for widget picker preview
let sampleStats = MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1])
return VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .good, stats: sampleStats)
}
func getSnapshot(in context: Context, completion: @escaping (VoteWidgetEntry) -> Void) {
// Show sample data for widget picker preview
if context.isPreview {
let sampleStats = MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1])
let entry = VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .good, stats: sampleStats)
completion(entry)
return
}
let entry = createEntry()
completion(entry)
}
@@ -210,13 +221,12 @@ struct MoodButton: View {
.foregroundColor(moodTint.color(forMood: mood))
if !isCompact {
Text(mood.strValue)
Text(mood.widgetDisplayName)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
.buttonStyle(.plain)
}
}
@@ -248,7 +258,7 @@ struct VotedStatsView: View {
Text("Today")
.font(.caption)
.foregroundStyle(.secondary)
Text(mood.strValue)
Text(mood.widgetDisplayName)
.font(.headline)
.foregroundColor(moodTint.color(forMood: mood))
}

View File

@@ -30,18 +30,18 @@ class WatchTimelineView: Identifiable {
struct TimeLineCreator {
static func createViews(daysBack: Int) -> [WatchTimelineView] {
var timeLineView = [WatchTimelineView]()
let latestDayToShow = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
let dates = Array(0...daysBack).map({
Calendar.current.date(byAdding: .day, value: -$0, to: latestDayToShow)!
})
for date in dates {
let dayStart = Calendar.current.startOfDay(for: date)
let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
let moodImages: MoodImagable.Type = UserDefaultsStore.moodMoodImagable()
if let todayEntry = PersistenceController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first {
timeLineView.append(WatchTimelineView(image: moodImages.icon(forMood: todayEntry.mood),
graphic: moodImages.icon(forMood: todayEntry.mood),
@@ -51,15 +51,39 @@ struct TimeLineCreator {
} else {
timeLineView.append(WatchTimelineView(image: moodImages.icon(forMood: .missing),
graphic: moodImages.icon(forMood: .missing),
date: Date(),
date: dayStart,
color: moodTint.color(forMood: .missing),
secondaryColor: moodTint.secondary(forMood: .missing)))
}
}
timeLineView = timeLineView.sorted(by: { $0.date > $1.date })
return timeLineView
}
/// Creates sample preview data for widget picker - shows what widget looks like with mood data
static func createSampleViews(count: Int) -> [WatchTimelineView] {
var timeLineView = [WatchTimelineView]()
let sampleMoods: [Mood] = [.great, .good, .average, .good, .great, .average, .bad, .good, .great, .good, .average]
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
let moodImages: MoodImagable.Type = UserDefaultsStore.moodMoodImagable()
for i in 0..<count {
let date = Calendar.current.date(byAdding: .day, value: -i, to: Date())!
let dayStart = Calendar.current.startOfDay(for: date)
let mood = sampleMoods[i % sampleMoods.count]
timeLineView.append(WatchTimelineView(
image: moodImages.icon(forMood: mood),
graphic: moodImages.icon(forMood: mood),
date: dayStart,
color: moodTint.color(forMood: mood),
secondaryColor: moodTint.secondary(forMood: mood)
))
}
return timeLineView
}
}
struct Provider: IntentTimelineProvider {
@@ -67,18 +91,25 @@ struct Provider: IntentTimelineProvider {
/*
placeholder for widget, no data
gets redacted auto
gets redacted auto - uses sample data for widget picker preview
*/
func placeholder(in context: Context) -> SimpleEntry {
return SimpleEntry(date: Date(),
configuration: ConfigurationIntent(),
timeLineViews: Array(TimeLineCreator.createViews(daysBack: 11).prefix(10)))
timeLineViews: TimeLineCreator.createSampleViews(count: 10))
}
func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
// Use sample data for widget picker preview, real data otherwise
let timeLineViews: [WatchTimelineView]
if context.isPreview {
timeLineViews = TimeLineCreator.createSampleViews(count: 10)
} else {
timeLineViews = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
}
let entry = SimpleEntry(date: Date(),
configuration: ConfigurationIntent(),
timeLineViews: Array(TimeLineCreator.createViews(daysBack: 11).prefix(10)))
timeLineViews: timeLineViews)
completion(entry)
}
@@ -144,12 +175,18 @@ struct FeelsWidgetEntryView : View {
struct SmallWidgetView: View {
var entry: Provider.Entry
var timeLineView = [WatchTimelineView]()
init(entry: Provider.Entry) {
self.entry = entry
timeLineView = [TimeLineCreator.createViews(daysBack: 2).first!]
let realData = TimeLineCreator.createViews(daysBack: 2)
// Check if we have any real mood data (not all missing)
let hasRealData = realData.contains { view in
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
timeLineView = hasRealData ? [realData.first!] : [TimeLineCreator.createSampleViews(count: 1).first!]
}
var body: some View {
ZStack {
Color(UIColor.secondarySystemBackground)
@@ -169,25 +206,31 @@ struct SmallWidgetView: View {
struct MediumWidgetView: View {
var entry: Provider.Entry
var timeLineView = [WatchTimelineView]()
init(entry: Provider.Entry) {
self.entry = entry
timeLineView = Array(TimeLineCreator.createViews(daysBack: 6).prefix(5))
let realData = Array(TimeLineCreator.createViews(daysBack: 6).prefix(5))
// Check if we have any real mood data (not all missing)
let hasRealData = realData.contains { view in
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 5)
}
var body: some View {
VStack {
Spacer()
TimeHeaderView(startDate: timeLineView.first!.date, endDate: timeLineView.last!.date)
.frame(minWidth: 0, maxWidth: .infinity)
.multilineTextAlignment(.leading)
TimeBodyView(group: timeLineView)
.clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous))
.frame(minHeight: 0, maxHeight: 55)
.padding()
Spacer()
}
}
@@ -196,41 +239,47 @@ struct MediumWidgetView: View {
struct LargeWidgetView: View {
var entry: Provider.Entry
var timeLineView = [WatchTimelineView]()
init(entry: Provider.Entry) {
self.entry = entry
timeLineView = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
let realData = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
// Check if we have any real mood data (not all missing)
let hasRealData = realData.contains { view in
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 10)
}
var firstGroup: ([WatchTimelineView], String) {
return (Array(self.timeLineView.prefix(5)), UUID().uuidString)
}
var secondGroup: ([WatchTimelineView], String) {
return (Array(self.timeLineView.suffix(5)), UUID().uuidString)
}
var body: some View {
VStack {
Spacer()
ForEach([firstGroup, secondGroup], id: \.1) { group in
VStack {
Spacer()
TimeHeaderView(startDate: group.0.first!.date, endDate: group.0.last!.date)
.frame(minWidth: 0, maxWidth: .infinity)
.multilineTextAlignment(.leading)
TimeBodyView(group: group.0)
.clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous))
.frame(minHeight: 0, maxHeight: 55)
.padding()
Spacer()
}
}
Spacer()
}
}
@@ -255,12 +304,18 @@ struct FeelsGraphicWidgetEntryView : View {
struct SmallGraphicWidgetView: View {
var entry: Provider.Entry
var timeLineView: [WatchTimelineView]
init(entry: Provider.Entry) {
self.entry = entry
timeLineView = TimeLineCreator.createViews(daysBack: 2)
let realData = TimeLineCreator.createViews(daysBack: 2)
// Check if we have any real mood data (not all missing)
let hasRealData = realData.contains { view in
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 2)
}
var body: some View {
if let first = timeLineView.first {
IconView(iconViewModel: IconViewModel(backgroundImage: first.graphic,

View File

@@ -15,7 +15,6 @@
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.tt.ifeelDebug</string>
<string></string>
</array>
</dict>
</plist>

View File

@@ -24,6 +24,11 @@ enum SubscriptionState: Equatable {
@MainActor
class IAPManager: ObservableObject {
// MARK: - Debug Toggle
/// Set to `true` to bypass all subscription checks and grant full access (for development only)
static let bypassSubscription = true
// MARK: - Constants
static let subscriptionGroupID = "2CFE4C4F"
@@ -59,6 +64,7 @@ class IAPManager: ObservableObject {
}
var hasFullAccess: Bool {
if Self.bypassSubscription { return true }
switch state {
case .subscribed, .inTrial:
return true
@@ -68,6 +74,7 @@ class IAPManager: ObservableObject {
}
var shouldShowPaywall: Bool {
if Self.bypassSubscription { return false }
switch state {
case .trialExpired, .expired:
return true
@@ -134,7 +141,8 @@ class IAPManager: ObservableObject {
/// Sync subscription status to UserDefaults for widget access
private func syncSubscriptionStatusToUserDefaults() {
GroupUserDefaults.groupDefaults.set(hasFullAccess, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
let accessValue = Self.bypassSubscription ? true : hasFullAccess
GroupUserDefaults.groupDefaults.set(accessValue, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
}
/// Restore purchases

View File

@@ -44,6 +44,19 @@ enum Mood: Int {
return String("placeholder")
}
}
/// Non-localized display name for use in widgets (which don't have access to app's localization)
var widgetDisplayName: String {
switch self {
case .horrible: return "Horrible"
case .bad: return "Bad"
case .average: return "Average"
case .good: return "Good"
case .great: return "Great"
case .missing: return "Missing"
case .placeholder: return "Placeholder"
}
}
var color: Color {
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()