diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index f3c88c9..c26dbbc 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -15,7 +15,6 @@
"Bash(swift -parse:*)",
"Bash(swiftc:*)",
"Bash(git add:*)",
- "Bash(git commit:*)",
"WebFetch(domain:apps.apple.com)",
"Bash(ls:*)",
"Bash(python3:*)",
@@ -23,6 +22,10 @@
"Bash( comm -13 /tmp/code_keys.txt /tmp/xcstrings_keys.txt)",
"Bash(xargs cat:*)",
"Bash(xcrun simctl:*)"
+ ],
+ "ask": [
+ "Bash(git commit:*)",
+ "Bash(git push:*)"
]
}
}
diff --git a/Configuration.storekit b/Configuration.storekit
index 629f75a..71a9557 100644
--- a/Configuration.storekit
+++ b/Configuration.storekit
@@ -73,34 +73,6 @@
],
"name" : "group1",
"subscriptions" : [
- {
- "adHocOffers" : [
-
- ],
- "codeOffers" : [
-
- ],
- "displayPrice" : "0.99",
- "familyShareable" : false,
- "groupNumber" : 1,
- "internalID" : "44A8029E",
- "introductoryOffer" : null,
- "localizations" : [
- {
- "description" : "weekly desc",
- "displayName" : "Weekly",
- "locale" : "en_US"
- }
- ],
- "productID" : "com.tt.ifeel.IAP.subscriptions.weekly",
- "recurringSubscriptionPeriod" : "P1W",
- "referenceName" : "Weekly",
- "subscriptionGroupID" : "2CFE4C4F",
- "type" : "RecurringSubscription",
- "winbackOffers" : [
-
- ]
- },
{
"adHocOffers" : [
@@ -115,7 +87,7 @@
"introductoryOffer" : null,
"localizations" : [
{
- "description" : "montly desc",
+ "description" : "Flexible month-to-month billing",
"displayName" : "Monthly",
"locale" : "en_US"
}
@@ -136,14 +108,14 @@
"codeOffers" : [
],
- "displayPrice" : "4.99",
+ "displayPrice" : "9.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "32967821",
"introductoryOffer" : null,
"localizations" : [
{
- "description" : "yearly desc",
+ "description" : "Best value — save over 15%",
"displayName" : "Yearly",
"locale" : "en_US"
}
diff --git a/Feels.xcodeproj/xcuserdata/treyt.xcuserdatad/xcschemes/xcschememanagement.plist b/Feels.xcodeproj/xcuserdata/treyt.xcuserdatad/xcschemes/xcschememanagement.plist
index 218c6d9..cb40cc7 100644
--- a/Feels.xcodeproj/xcuserdata/treyt.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/Feels.xcodeproj/xcuserdata/treyt.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -12,12 +12,12 @@
Feels (macOS).xcscheme_^#shared#^_
orderHint
- 3
+ 2
Feels Watch App.xcscheme_^#shared#^_
orderHint
- 2
+ 3
FeelsWidgetExtension.xcscheme_^#shared#^_
diff --git a/Feels/Localizable.xcstrings b/Feels/Localizable.xcstrings
index 593d401..4d55c95 100644
--- a/Feels/Localizable.xcstrings
+++ b/Feels/Localizable.xcstrings
@@ -842,6 +842,10 @@
"comment" : "A description of the feature that allows users to sync their mood data with Apple Health.",
"isCommentAutoGenerated" : true
},
+ "Count" : {
+ "comment" : "Label for the count of a mood in the header stats view.",
+ "isCommentAutoGenerated" : true
+ },
"Create random icons" : {
},
@@ -1366,6 +1370,10 @@
"comment" : "A description of what the \"Export Data\" button does.",
"isCommentAutoGenerated" : true
},
+ "Current: %@" : {
+ "comment" : "A text view displaying the current date and time of the first app launch.",
+ "isCommentAutoGenerated" : true
+ },
"Custom" : {
"comment" : "The text that appears as a label for the custom color option in the tint picker.",
"isCommentAutoGenerated" : true
@@ -1672,6 +1680,10 @@
"comment" : "A label displayed below the number of days a user has tracked their mood.",
"isCommentAutoGenerated" : true
},
+ "Debug" : {
+ "comment" : "A section header in the settings view, hidden in release builds.",
+ "isCommentAutoGenerated" : true
+ },
"default_notif_body_today_four" : {
"extractionState" : "manual",
"localizations" : {
@@ -2205,6 +2217,10 @@
"comment" : "A tip title for a feature that provides personalized insights about mood patterns.",
"isCommentAutoGenerated" : true
},
+ "Discover patterns in your mood, get personalized recommendations, and understand what affects how you feel." : {
+ "comment" : "A description of the benefits of using the app's AI-powered insights feature.",
+ "isCommentAutoGenerated" : true
+ },
"Don't break your streak!" : {
"comment" : "A description of the current streak or a motivational message.",
"isCommentAutoGenerated" : true
@@ -2343,6 +2359,10 @@
"comment" : "Title of an intent that checks the user's current mood logging streak.",
"isCommentAutoGenerated" : true
},
+ "Get Personal Insights" : {
+ "comment" : "A button label that encourages users to subscribe for more personalized insights.",
+ "isCommentAutoGenerated" : true
+ },
"Get personalized insights about your mood patterns powered by Apple Intelligence." : {
"comment" : "A message accompanying the \"Discover AI Insights\" tip, encouraging users to explore their mood patterns with Apple Intelligence.",
"isCommentAutoGenerated" : true
@@ -4119,6 +4139,10 @@
},
"Premium Active" : {
+ },
+ "Premium Feature" : {
+ "comment" : "A label indicating a premium feature.",
+ "isCommentAutoGenerated" : true
},
"Privacy Lock" : {
"comment" : "A title for a toggle that controls whether or not biometric authentication is enabled.",
@@ -5355,8 +5379,8 @@
"comment" : "Title of an intent that allows the user to check their logged mood for the current day.",
"isCommentAutoGenerated" : true
},
- "Set first launch date back 29 days, 23 hrs, 45 seconds" : {
- "comment" : "A button that, when tapped, sets the first launch date back to a specific date.",
+ "Set Trial Start Date" : {
+ "comment" : "The title of a screen that lets a user set the start date of a free trial.",
"isCommentAutoGenerated" : true
},
"Settings" : {
@@ -5938,13 +5962,6 @@
"SIDE B - NO RECORDING" : {
"comment" : "A message displayed when a user's mood entry is missing for a particular day.",
"isCommentAutoGenerated" : true
- },
- "Start Free Trial" : {
- "comment" : "A button label that says \"Start Free Trial\".",
- "isCommentAutoGenerated" : true
- },
- "Start your free 7-day trial" : {
-
},
"Streak: %lld days" : {
"comment" : "A label in the expanded view that describes the current streak of days the user has logged in.",
@@ -5954,6 +5971,10 @@
"comment" : "A button label that says \"Subscribe\".",
"isCommentAutoGenerated" : true
},
+ "Subscribe to see your full year" : {
+ "comment" : "A button label that appears when the user is subscribed to Feels.",
+ "isCommentAutoGenerated" : true
+ },
"subscription_required_button" : {
"extractionState" : "manual",
"localizations" : {
@@ -6280,6 +6301,10 @@
"comment" : "A prefix for the text that displays how many days are left in a user's free trial.",
"isCommentAutoGenerated" : true
},
+ "Trial Start Date" : {
+ "comment" : "A label describing the trial start date setting.",
+ "isCommentAutoGenerated" : true
+ },
"Try Again" : {
"comment" : "A button that allows the user to try authenticating again if the initial attempt fails.",
"isCommentAutoGenerated" : true
@@ -6288,6 +6313,10 @@
"comment" : "An alert message displayed when biometric authentication fails.",
"isCommentAutoGenerated" : true
},
+ "Unlock AI-Powered Insights" : {
+ "comment" : "A title for a button that allows users to unlock premium insights.",
+ "isCommentAutoGenerated" : true
+ },
"Unlock Premium" : {
"comment" : "A button label that says \"Unlock Premium\".",
"isCommentAutoGenerated" : true
diff --git a/FeelsWidget2/FeelsVoteWidget.swift b/FeelsWidget2/FeelsVoteWidget.swift
index 29c6407..efd803d 100644
--- a/FeelsWidget2/FeelsVoteWidget.swift
+++ b/FeelsWidget2/FeelsVoteWidget.swift
@@ -158,28 +158,26 @@ struct FeelsVoteWidgetEntryView: View {
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, promptText: entry.promptText)
- }
+ if entry.hasVotedToday {
+ // Already voted today - show stats (regardless of subscription status)
+ VotedStatsView(entry: entry)
} else {
- // Non-subscriber view - tap to open app
- NonSubscriberView()
+ // Not voted yet - show voting buttons
+ // If subscribed/in trial: buttons record votes
+ // If trial expired: buttons open app
+ VotingView(family: family, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
}
}
.containerBackground(.fill.tertiary, for: .widget)
}
}
-// MARK: - Voting View (for subscribers who haven't voted)
+// MARK: - Voting View
struct VotingView: View {
let family: WidgetFamily
let promptText: String
+ let hasSubscription: Bool
private var moodTint: MoodTintable.Type {
UserDefaultsStore.moodTintable()
@@ -200,7 +198,7 @@ struct VotingView: View {
// MARK: - Small Widget: 3 over 2 grid
private var smallLayout: some View {
VStack(spacing: 0) {
- Text(promptText)
+ Text(hasSubscription ? promptText : "Tap to open app")
.font(.caption)
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
@@ -230,7 +228,7 @@ struct VotingView: View {
// MARK: - Medium Widget: Single row
private var mediumLayout: some View {
VStack {
- Text(promptText)
+ Text(hasSubscription ? promptText : "Subscribe to track your mood")
.font(.headline)
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
@@ -247,15 +245,37 @@ struct VotingView: View {
.padding()
}
+ @ViewBuilder
private func moodButton(for mood: Mood, size: CGFloat) -> some View {
- Button(intent: VoteMoodIntent(mood: mood)) {
- moodImages.icon(forMood: mood)
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: size, height: size)
- .foregroundColor(moodTint.color(forMood: mood))
+ // Ensure minimum 44x44 touch target for accessibility
+ let touchSize = max(size, 44)
+
+ if hasSubscription {
+ // Active subscription: vote normally
+ Button(intent: VoteMoodIntent(mood: mood)) {
+ moodIcon(for: mood, size: size)
+ .frame(minWidth: touchSize, minHeight: touchSize)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(mood.strValue)
+ .accessibilityHint(String(localized: "Log this mood"))
+ } else {
+ // Trial expired: open app to subscribe
+ Link(destination: URL(string: "feels://subscribe")!) {
+ moodIcon(for: mood, size: size)
+ .frame(minWidth: touchSize, minHeight: touchSize)
+ }
+ .accessibilityLabel(mood.strValue)
+ .accessibilityHint(String(localized: "Open app to subscribe"))
}
- .buttonStyle(.plain)
+ }
+
+ private func moodIcon(for mood: Mood, size: CGFloat) -> some View {
+ moodImages.icon(forMood: mood)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: size, height: size)
+ .foregroundColor(moodTint.color(forMood: mood))
}
}
@@ -311,6 +331,8 @@ struct VotedStatsView: View {
.foregroundStyle(.tertiary)
}
}
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(String(localized: "Mood logged: \(entry.todaysMood?.strValue ?? "")"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(12)
@@ -407,6 +429,8 @@ struct NonSubscriberView: View {
.padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
+ .accessibilityLabel(String(localized: "Track Your Mood"))
+ .accessibilityHint(String(localized: "Tap to open app and subscribe"))
}
}
diff --git a/FeelsWidget2/FeelsWidget.swift b/FeelsWidget2/FeelsWidget.swift
index 4da17f1..7adfe3e 100644
--- a/FeelsWidget2/FeelsWidget.swift
+++ b/FeelsWidget2/FeelsWidget.swift
@@ -308,7 +308,7 @@ struct FeelsWidgetEntryView : View {
var entry: Provider.Entry
private var showVotingForToday: Bool {
- entry.hasSubscription && !entry.hasVotedToday
+ !entry.hasVotedToday
}
@ViewBuilder
@@ -338,7 +338,7 @@ struct SmallWidgetView: View {
var todayView: WatchTimelineView?
private var showVotingForToday: Bool {
- entry.hasSubscription && !entry.hasVotedToday
+ !entry.hasVotedToday
}
private var dayFormatter: DateFormatter {
@@ -365,8 +365,8 @@ struct SmallWidgetView: View {
var body: some View {
if showVotingForToday {
- // Show interactive voting buttons
- VotingView(family: .systemSmall, promptText: entry.promptText)
+ // Show interactive voting buttons (or open app links if expired)
+ VotingView(family: .systemSmall, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
} else if let today = todayView {
VStack(spacing: 0) {
Spacer()
@@ -405,7 +405,7 @@ struct MediumWidgetView: View {
var timeLineView = [WatchTimelineView]()
private var showVotingForToday: Bool {
- entry.hasSubscription && !entry.hasVotedToday
+ !entry.hasVotedToday
}
private var dayFormatter: DateFormatter {
@@ -439,8 +439,8 @@ struct MediumWidgetView: View {
var body: some View {
if showVotingForToday {
- // Show interactive voting buttons
- VotingView(family: .systemMedium, promptText: entry.promptText)
+ // Show interactive voting buttons (or open app links if expired)
+ VotingView(family: .systemMedium, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
} else {
GeometryReader { geo in
let cellHeight = geo.size.height - 36
@@ -524,7 +524,7 @@ struct LargeWidgetView: View {
var timeLineView = [WatchTimelineView]()
private var showVotingForToday: Bool {
- entry.hasSubscription && !entry.hasVotedToday
+ !entry.hasVotedToday
}
init(entry: Provider.Entry) {
@@ -551,8 +551,8 @@ struct LargeWidgetView: View {
var body: some View {
if showVotingForToday {
- // Show interactive voting buttons for large widget
- LargeVotingView(promptText: entry.promptText)
+ // Show interactive voting buttons for large widget (or open app links if expired)
+ LargeVotingView(promptText: entry.promptText, hasSubscription: entry.hasSubscription)
} else {
GeometryReader { geo in
let cellHeight = (geo.size.height - 70) / 2 // Subtract header height, divide by 2 rows
@@ -668,6 +668,7 @@ struct DayCell: View {
struct LargeVotingView: View {
let promptText: String
+ let hasSubscription: Bool
private var moodTint: MoodTintable.Type {
UserDefaultsStore.moodTintable()
@@ -681,7 +682,7 @@ struct LargeVotingView: View {
VStack(spacing: 24) {
Spacer()
- Text(promptText)
+ Text(hasSubscription ? promptText : "Subscribe to track your mood")
.font(.title2.weight(.semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
@@ -691,26 +692,7 @@ struct LargeVotingView: View {
// Large mood buttons in a row
HStack(spacing: 20) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
- Button(intent: VoteMoodIntent(mood: mood)) {
- VStack(spacing: 8) {
- moodImages.icon(forMood: mood)
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 56, height: 56)
- .foregroundColor(moodTint.color(forMood: mood))
-
- Text(mood.strValue)
- .font(.caption.weight(.medium))
- .foregroundColor(moodTint.color(forMood: mood))
- }
- .padding(.vertical, 12)
- .padding(.horizontal, 8)
- .background(
- RoundedRectangle(cornerRadius: 16)
- .fill(moodTint.color(forMood: mood).opacity(0.15))
- )
- }
- .buttonStyle(.plain)
+ moodButton(for: mood)
}
}
@@ -718,6 +700,40 @@ struct LargeVotingView: View {
}
.padding()
}
+
+ @ViewBuilder
+ private func moodButton(for mood: Mood) -> some View {
+ if hasSubscription {
+ Button(intent: VoteMoodIntent(mood: mood)) {
+ moodButtonContent(for: mood)
+ }
+ .buttonStyle(.plain)
+ } else {
+ Link(destination: URL(string: "feels://subscribe")!) {
+ moodButtonContent(for: mood)
+ }
+ }
+ }
+
+ private func moodButtonContent(for mood: Mood) -> some View {
+ VStack(spacing: 8) {
+ moodImages.icon(forMood: mood)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 56, height: 56)
+ .foregroundColor(moodTint.color(forMood: mood))
+
+ Text(mood.strValue)
+ .font(.caption.weight(.medium))
+ .foregroundColor(moodTint.color(forMood: mood))
+ }
+ .padding(.vertical, 12)
+ .padding(.horizontal, 8)
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(moodTint.color(forMood: mood).opacity(0.15))
+ )
+ }
}
/**********************************************************/
@@ -823,11 +839,12 @@ struct TimeBodyView: View {
let group: [WatchTimelineView]
var showVotingForToday: Bool = false
var promptText: String = ""
+ var hasSubscription: Bool = false
var body: some View {
if showVotingForToday {
// Show voting view without extra background container
- InlineVotingView(promptText: promptText)
+ InlineVotingView(promptText: promptText, hasSubscription: hasSubscription)
.padding()
} else {
ZStack {
@@ -847,6 +864,7 @@ struct TimeBodyView: View {
struct InlineVotingView: View {
let promptText: String
+ let hasSubscription: Bool
let moods: [Mood] = [.horrible, .bad, .average, .good, .great]
private var moodTint: MoodTintable.Type {
@@ -859,7 +877,7 @@ struct InlineVotingView: View {
var body: some View {
VStack(spacing: 8) {
- Text(promptText)
+ Text(hasSubscription ? promptText : "Tap to open app")
.font(.subheadline)
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
@@ -868,18 +886,33 @@ struct InlineVotingView: View {
HStack(spacing: 8) {
ForEach(moods, id: \.rawValue) { mood in
- Button(intent: VoteMoodIntent(mood: mood)) {
- moodImages.icon(forMood: mood)
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 44, height: 44)
- .foregroundColor(moodTint.color(forMood: mood))
- }
- .buttonStyle(.plain)
+ moodButton(for: mood)
}
}
}
}
+
+ @ViewBuilder
+ private func moodButton(for mood: Mood) -> some View {
+ if hasSubscription {
+ Button(intent: VoteMoodIntent(mood: mood)) {
+ moodIcon(for: mood)
+ }
+ .buttonStyle(.plain)
+ } else {
+ Link(destination: URL(string: "feels://subscribe")!) {
+ moodIcon(for: mood)
+ }
+ }
+ }
+
+ private func moodIcon(for mood: Mood) -> some View {
+ moodImages.icon(forMood: mood)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 44, height: 44)
+ .foregroundColor(moodTint.color(forMood: mood))
+ }
}
struct EntryCard: View {
diff --git a/Shared/IAPManager.swift b/Shared/IAPManager.swift
index a61f058..cd69075 100644
--- a/Shared/IAPManager.swift
+++ b/Shared/IAPManager.swift
@@ -25,6 +25,11 @@ enum SubscriptionState: Equatable {
@MainActor
class IAPManager: ObservableObject {
+ // MARK: - Shared Instance
+
+ /// Shared instance for service-level access (e.g., HealthKit gating)
+ static let shared = IAPManager()
+
// MARK: - Debug Toggle
/// Set to `true` to bypass all subscription checks and grant full access (for development only)
diff --git a/Shared/MoodLogger.swift b/Shared/MoodLogger.swift
index 204aa9b..2e3444b 100644
--- a/Shared/MoodLogger.swift
+++ b/Shared/MoodLogger.swift
@@ -64,10 +64,11 @@ final class MoodLogger {
Self.logger.info("Applying side effects for mood \(mood.rawValue) on \(date)")
- // 1. Sync to HealthKit if enabled and requested
+ // 1. Sync to HealthKit if enabled, requested, and user has full access
if syncHealthKit {
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
- if healthKitEnabled {
+ let hasAccess = !IAPManager.shared.shouldShowPaywall
+ if healthKitEnabled && hasAccess {
Task {
try? await HealthKitManager.shared.saveMood(mood, for: date)
}
diff --git a/Shared/Onboarding/views/OnboardingDay.swift b/Shared/Onboarding/views/OnboardingDay.swift
index 03dd4d2..a5c2096 100644
--- a/Shared/Onboarding/views/OnboardingDay.swift
+++ b/Shared/Onboarding/views/OnboardingDay.swift
@@ -127,6 +127,7 @@ struct DayOptionCard: View {
.font(.system(size: 20))
.foregroundColor(isSelected ? Color(hex: "4facfe") : .white)
}
+ .accessibilityHidden(true)
// Text
VStack(alignment: .leading, spacing: 3) {
@@ -152,6 +153,7 @@ struct DayOptionCard: View {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 22))
.foregroundColor(Color(hex: "4facfe"))
+ .accessibilityHidden(true)
}
}
.padding(.horizontal, 16)
@@ -163,6 +165,9 @@ struct DayOptionCard: View {
)
}
.buttonStyle(.plain)
+ .accessibilityLabel("\(title), \(subtitle)")
+ .accessibilityHint(example)
+ .accessibilityAddTraits(isSelected ? [.isSelected] : [])
}
}
diff --git a/Shared/Onboarding/views/OnboardingStyle.swift b/Shared/Onboarding/views/OnboardingStyle.swift
index 5bc8744..c6d05f3 100644
--- a/Shared/Onboarding/views/OnboardingStyle.swift
+++ b/Shared/Onboarding/views/OnboardingStyle.swift
@@ -22,48 +22,46 @@ struct OnboardingStyle: View {
)
.ignoresSafeArea()
- VStack(spacing: 0) {
- Spacer()
+ ScrollView(showsIndicators: false) {
+ VStack(spacing: 0) {
+ // Icon
+ ZStack {
+ Circle()
+ .fill(.white.opacity(0.15))
+ .frame(width: 100, height: 100)
- // Icon
- ZStack {
- Circle()
- .fill(.white.opacity(0.15))
- .frame(width: 120, height: 120)
+ Image(systemName: "paintpalette.fill")
+ .font(.system(size: 40))
+ .foregroundColor(.white)
+ }
+ .padding(.top, 40)
+ .padding(.bottom, 20)
- Image(systemName: "paintpalette.fill")
- .font(.system(size: 44))
+ // Title
+ Text("Make it yours")
+ .font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundColor(.white)
- }
- .padding(.bottom, 32)
+ .padding(.bottom, 8)
- // Title
- Text("Make it yours")
- .font(.system(size: 28, weight: .bold, design: .rounded))
- .foregroundColor(.white)
- .padding(.bottom, 12)
+ // Subtitle
+ Text("Choose your favorite style")
+ .font(.system(size: 16, weight: .medium))
+ .foregroundColor(.white.opacity(0.85))
+ .padding(.bottom, 20)
- // Subtitle
- Text("Choose your favorite style")
- .font(.system(size: 16, weight: .medium))
- .foregroundColor(.white.opacity(0.85))
-
- Spacer()
-
- // Preview card
- OnboardingStylePreview(moodTint: moodTint, imagePack: imagePack)
- .padding(.horizontal, 24)
- .padding(.bottom, 24)
-
- // Icon Style Section
- VStack(alignment: .leading, spacing: 12) {
- Text("Icon Style")
- .font(.system(size: 14, weight: .semibold))
- .foregroundColor(.white.opacity(0.9))
+ // Preview card
+ OnboardingStylePreview(moodTint: moodTint, imagePack: imagePack)
.padding(.horizontal, 24)
+ .padding(.bottom, 20)
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 12) {
+ // Icon Style Section
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Icon Style")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(.white.opacity(0.9))
+ .padding(.horizontal, 24)
+
+ LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
ForEach(MoodImages.allCases, id: \.rawValue) { pack in
OnboardingIconPackOption(
pack: pack,
@@ -79,18 +77,16 @@ struct OnboardingStyle: View {
}
.padding(.horizontal, 24)
}
- }
- .padding(.bottom, 20)
+ .padding(.bottom, 16)
- // Color Theme Section
- VStack(alignment: .leading, spacing: 12) {
- Text("Mood Colors")
- .font(.system(size: 14, weight: .semibold))
- .foregroundColor(.white.opacity(0.9))
- .padding(.horizontal, 24)
+ // Color Theme Section
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Mood Colors")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(.white.opacity(0.9))
+ .padding(.horizontal, 24)
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 12) {
+ LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
ForEach(MoodTints.defaultOptions, id: \.rawValue) { tint in
OnboardingTintOption(
tint: tint,
@@ -105,19 +101,18 @@ struct OnboardingStyle: View {
}
.padding(.horizontal, 24)
}
- }
- Spacer()
-
- // Hint
- HStack(spacing: 8) {
- Image(systemName: "arrow.left.arrow.right")
- .font(.system(size: 14))
- Text("You can change these anytime in Customize")
- .font(.system(size: 13, weight: .medium))
+ // Hint
+ HStack(spacing: 8) {
+ Image(systemName: "arrow.left.arrow.right")
+ .font(.system(size: 14))
+ Text("You can change these anytime in Customize")
+ .font(.system(size: 13, weight: .medium))
+ }
+ .foregroundColor(.white.opacity(0.7))
+ .padding(.top, 20)
+ .padding(.bottom, 80)
}
- .foregroundColor(.white.opacity(0.7))
- .padding(.bottom, 80)
}
}
}
diff --git a/Shared/Onboarding/views/OnboardingSubscription.swift b/Shared/Onboarding/views/OnboardingSubscription.swift
index 326a1ca..b57d300 100644
--- a/Shared/Onboarding/views/OnboardingSubscription.swift
+++ b/Shared/Onboarding/views/OnboardingSubscription.swift
@@ -46,12 +46,6 @@ struct OnboardingSubscription: View {
.multilineTextAlignment(.center)
.padding(.bottom, 8)
- // Trial info
- Text("Start your free 7-day trial")
- .font(.system(size: 16, weight: .semibold))
- .foregroundColor(.white.opacity(0.9))
- .padding(.bottom, 24)
-
Spacer()
// Benefits list
@@ -67,7 +61,7 @@ struct OnboardingSubscription: View {
BenefitRow(
icon: "lightbulb.fill",
- title: "Smart Insights",
+ title: "AI-Powered Insights",
description: "Discover trends and patterns in your moods"
)
@@ -75,27 +69,18 @@ struct OnboardingSubscription: View {
.background(.white.opacity(0.2))
BenefitRow(
- icon: "clock.arrow.circlepath",
- title: "Unlimited History",
- description: "Access all your past mood data forever"
+ icon: "heart.text.square.fill",
+ title: "Health Data Correlation",
+ description: "Connect your mood with sleep, steps, and more"
)
Divider()
.background(.white.opacity(0.2))
BenefitRow(
- icon: "icloud.fill",
- title: "Cloud Sync",
- description: "Your data synced across all your devices"
- )
-
- Divider()
- .background(.white.opacity(0.2))
-
- BenefitRow(
- icon: "heart.fill",
- title: "Support Development",
- description: "Help us keep improving Feels for everyone"
+ icon: "square.grid.2x2.fill",
+ title: "Interactive Widgets",
+ description: "Log your mood directly from your home screen"
)
}
.padding(.vertical, 8)
@@ -118,7 +103,7 @@ struct OnboardingSubscription: View {
Image(systemName: "sparkles")
.font(.system(size: 18, weight: .semibold))
- Text("Start Free Trial")
+ Text("Get Personal Insights")
.font(.system(size: 18, weight: .bold))
}
.foregroundColor(Color(hex: "11998e"))
@@ -130,6 +115,8 @@ struct OnboardingSubscription: View {
.shadow(color: .black.opacity(0.15), radius: 10, y: 5)
)
}
+ .accessibilityLabel(String(localized: "Get Personal Insights"))
+ .accessibilityHint(String(localized: "Opens subscription options"))
// Skip button
Button(action: {
@@ -141,6 +128,8 @@ struct OnboardingSubscription: View {
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white.opacity(0.8))
}
+ .accessibilityLabel(String(localized: "Maybe Later"))
+ .accessibilityHint(String(localized: "Skip subscription and complete setup"))
.padding(.top, 4)
}
.padding(.horizontal, 24)
@@ -168,6 +157,7 @@ struct BenefitRow: View {
.font(.system(size: 22))
.foregroundColor(.white)
.frame(width: 40)
+ .accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(title)
@@ -183,6 +173,8 @@ struct BenefitRow: View {
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("\(title): \(description)")
}
}
diff --git a/Shared/Onboarding/views/OnboardingTime.swift b/Shared/Onboarding/views/OnboardingTime.swift
index 73157e5..0b1ea63 100644
--- a/Shared/Onboarding/views/OnboardingTime.swift
+++ b/Shared/Onboarding/views/OnboardingTime.swift
@@ -63,6 +63,8 @@ struct OnboardingTime: View {
.datePickerStyle(.wheel)
.labelsHidden()
.colorScheme(.light)
+ .accessibilityLabel(String(localized: "Reminder time"))
+ .accessibilityHint(String(localized: "Select when you want to be reminded"))
}
.padding(.vertical, 20)
.padding(.horizontal, 24)
@@ -80,6 +82,7 @@ struct OnboardingTime: View {
Image(systemName: "info.circle.fill")
.font(.system(size: 20))
.foregroundColor(.white.opacity(0.8))
+ .accessibilityHidden(true)
Text("You'll get a gentle reminder at \(formatter.string(from: onboardingData.date)) every day")
.font(.system(size: 14, weight: .medium))
@@ -87,6 +90,7 @@ struct OnboardingTime: View {
}
.padding(.horizontal, 30)
.padding(.bottom, 80)
+ .accessibilityElement(children: .combine)
}
}
}
diff --git a/Shared/Onboarding/views/OnboardingWelcome.swift b/Shared/Onboarding/views/OnboardingWelcome.swift
index 8f87360..26536ce 100644
--- a/Shared/Onboarding/views/OnboardingWelcome.swift
+++ b/Shared/Onboarding/views/OnboardingWelcome.swift
@@ -71,6 +71,8 @@ struct OnboardingWelcome: View {
.foregroundColor(.white.opacity(0.7))
}
.padding(.bottom, 60)
+ .accessibilityLabel(String(localized: "Swipe right to continue"))
+ .accessibilityHint(String(localized: "Swipe to the next onboarding step"))
}
}
}
@@ -92,6 +94,7 @@ struct FeatureRow: View {
.font(.system(size: 22))
.foregroundColor(.white)
}
+ .accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(title)
@@ -105,6 +108,8 @@ struct FeatureRow: View {
Spacer()
}
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("\(title): \(description)")
}
}
diff --git a/Shared/Utilities/AccessibilityHelpers.swift b/Shared/Utilities/AccessibilityHelpers.swift
index 72b2367..ad9eb5d 100644
--- a/Shared/Utilities/AccessibilityHelpers.swift
+++ b/Shared/Utilities/AccessibilityHelpers.swift
@@ -90,6 +90,23 @@ extension Font {
static func scalable(_ style: Font.TextStyle, weight: Font.Weight = .regular) -> Font {
Font.system(style, design: .rounded).weight(weight)
}
+
+ /// Returns a custom-sized font that scales with Dynamic Type
+ static func scaledSystem(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default, relativeTo style: Font.TextStyle = .body) -> Font {
+ Font.system(size: size, weight: weight, design: design)
+ }
+}
+
+/// Property wrapper for scaled metrics that respect Dynamic Type
+@propertyWrapper
+struct ScaledValue: DynamicProperty {
+ @ScaledMetric private var scaledValue: CGFloat
+
+ var wrappedValue: CGFloat { scaledValue }
+
+ init(wrappedValue: CGFloat, relativeTo textStyle: Font.TextStyle = .body) {
+ _scaledValue = ScaledMetric(wrappedValue: wrappedValue, relativeTo: textStyle)
+ }
}
extension View {
@@ -117,3 +134,59 @@ struct AccessibilityAnnouncement {
UIAccessibility.post(notification: .layoutChanged, argument: focusElement)
}
}
+
+// MARK: - Reduce Motion View Modifier
+
+/// View modifier that provides alternative content when Reduce Motion is enabled
+struct ReduceMotionContent: ViewModifier {
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ let reducedContent: () -> Reduced
+
+ func body(content: Content) -> some View {
+ if reduceMotion {
+ reducedContent()
+ } else {
+ content
+ }
+ }
+}
+
+extension View {
+ /// Provides alternative content when Reduce Motion is enabled
+ func reduceMotionAlternative(@ViewBuilder _ reduced: @escaping () -> V) -> some View {
+ modifier(ReduceMotionContent(reducedContent: reduced))
+ }
+
+ /// Conditionally applies animation based on Reduce Motion setting
+ func animationIfAllowed(_ animation: Animation?, value: V) -> some View {
+ modifier(ConditionalAnimationModifier(animation: animation, value: value))
+ }
+}
+
+struct ConditionalAnimationModifier: ViewModifier {
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ let animation: Animation?
+ let value: V
+
+ func body(content: Content) -> some View {
+ if reduceMotion {
+ content
+ } else {
+ content.animation(animation, value: value)
+ }
+ }
+}
+
+// MARK: - VoiceOver Detection
+
+extension View {
+ /// Runs an action when VoiceOver is running
+ func onVoiceOverChange(perform action: @escaping (Bool) -> Void) -> some View {
+ self.onReceive(NotificationCenter.default.publisher(for: UIAccessibility.voiceOverStatusDidChangeNotification)) { _ in
+ action(UIAccessibility.isVoiceOverRunning)
+ }
+ .onAppear {
+ action(UIAccessibility.isVoiceOverRunning)
+ }
+ }
+}
diff --git a/Shared/Views/AddMoodHeaderView.swift b/Shared/Views/AddMoodHeaderView.swift
index 7d2be7d..f1dfcaa 100644
--- a/Shared/Views/AddMoodHeaderView.swift
+++ b/Shared/Views/AddMoodHeaderView.swift
@@ -93,8 +93,12 @@ struct HorizontalVotingView: View {
}
.buttonStyle(MoodButtonStyle())
.frame(maxWidth: .infinity)
+ .accessibilityLabel(mood.strValue)
+ .accessibilityHint(String(localized: "Select this mood"))
}
}
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(String(localized: "Mood selection"))
}
}
@@ -136,8 +140,12 @@ struct CardVotingView: View {
)
}
.buttonStyle(CardButtonStyle())
+ .accessibilityLabel(mood.strValue)
+ .accessibilityHint(String(localized: "Select this mood"))
}
}
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(String(localized: "Mood selection"))
}
}
@@ -177,10 +185,14 @@ struct RadialVotingView: View {
}
.buttonStyle(MoodButtonStyle())
.position(position)
+ .accessibilityLabel(mood.strValue)
+ .accessibilityHint(String(localized: "Select this mood"))
}
}
}
.frame(height: 180)
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(String(localized: "Mood selection"))
}
private func angleForIndex(_ index: Int, total: Int) -> Double {
@@ -233,8 +245,12 @@ struct StackedVotingView: View {
)
}
.buttonStyle(CardButtonStyle())
+ .accessibilityLabel(mood.strValue)
+ .accessibilityHint(String(localized: "Select this mood"))
}
}
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(String(localized: "Mood selection"))
}
}
@@ -323,36 +339,43 @@ struct AuraVotingView: View {
}
}
.buttonStyle(AuraButtonStyle(color: color))
+ .accessibilityLabel(mood.strValue)
+ .accessibilityHint(String(localized: "Select this mood"))
}
}
// Custom button style for aura with glow effect on press
struct AuraButtonStyle: ButtonStyle {
let color: Color
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.92 : 1.0)
.brightness(configuration.isPressed ? 0.1 : 0)
- .animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
+ .animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: configuration.isPressed)
}
}
// MARK: - Button Styles
struct MoodButtonStyle: ButtonStyle {
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.9 : 1.0)
- .animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
+ .animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: configuration.isPressed)
}
}
struct CardButtonStyle: ButtonStyle {
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.96 : 1.0)
.opacity(configuration.isPressed ? 0.8 : 1.0)
- .animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
+ .animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: configuration.isPressed)
}
}
diff --git a/Shared/Views/CustomizeView/SubViews/DayFilterPickerView.swift b/Shared/Views/CustomizeView/SubViews/DayFilterPickerView.swift
index ae10229..91ec702 100644
--- a/Shared/Views/CustomizeView/SubViews/DayFilterPickerView.swift
+++ b/Shared/Views/CustomizeView/SubViews/DayFilterPickerView.swift
@@ -29,17 +29,24 @@ struct DayFilterPickerView: View {
ForEach(weekdays.indices, id: \.self) { dayIdx in
let day = String(weekdays[dayIdx].0)
let value = weekdays[dayIdx].1
- Button(day.capitalized, action: {
- if filteredDays.currentFilters.contains(value) {
+ let isSelected = filteredDays.currentFilters.contains(value)
+ Button(action: {
+ if isSelected {
filteredDays.removeFilter(filter: value)
} else {
filteredDays.addFilter(newFilter: value)
}
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
impactMed.impactOccurred()
- })
- .frame(maxWidth: .infinity)
- .foregroundColor(filteredDays.currentFilters.contains(value) ? .green : .red)
+ }) {
+ Text(day.capitalized)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
+ .background(Color(uiColor: .tertiarySystemBackground))
+ .foregroundColor(isSelected ? .green : .red)
+ .cornerRadius(8)
+ }
+ .buttonStyle(.plain)
}
}
Text(String(localized: "day_picker_view_text"))
diff --git a/Shared/Views/DayView/DayView.swift b/Shared/Views/DayView/DayView.swift
index 5806ca1..e40cabc 100644
--- a/Shared/Views/DayView/DayView.swift
+++ b/Shared/Views/DayView/DayView.swift
@@ -174,6 +174,7 @@ extension DayView {
Image(systemName: "calendar")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(textColor.opacity(0.6))
+ .accessibilityHidden(true)
Text("\(Random.monthName(fromMonthInt: month)) \(String(year))")
.font(.system(size: 20, weight: .bold, design: .rounded))
@@ -184,6 +185,9 @@ extension DayView {
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(.ultraThinMaterial)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(String(localized: "\(Random.monthName(fromMonthInt: month)) \(String(year))"))
+ .accessibilityAddTraits(.isHeader)
}
private func auraSectionHeader(month: Int, year: Int) -> some View {
diff --git a/Shared/Views/DayView/DayViewViewModel.swift b/Shared/Views/DayView/DayViewViewModel.swift
index a404799..502941a 100644
--- a/Shared/Views/DayView/DayViewViewModel.swift
+++ b/Shared/Views/DayView/DayViewViewModel.swift
@@ -67,11 +67,12 @@ class DayViewViewModel: ObservableObject {
return
}
- // Sync to HealthKit for past day updates
+ // Sync to HealthKit for past day updates (only if user has full access)
guard mood != .missing && mood != .placeholder else { return }
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
- if healthKitEnabled {
+ let hasAccess = !IAPManager.shared.shouldShowPaywall
+ if healthKitEnabled && hasAccess {
Task {
try? await HealthKitManager.shared.saveMood(mood, for: entry.forDate)
}
diff --git a/Shared/Views/EntryListView.swift b/Shared/Views/EntryListView.swift
index ddcdfed..6968971 100644
--- a/Shared/Views/EntryListView.swift
+++ b/Shared/Views/EntryListView.swift
@@ -26,45 +26,63 @@ struct EntryListView: View {
}
var body: some View {
- switch dayViewStyle {
- case .classic:
- classicStyle
- case .minimal:
- minimalStyle
- case .compact:
- compactStyle
- case .bubble:
- bubbleStyle
- case .grid:
- gridStyle
- case .aura:
- auraStyle
- case .chronicle:
- chronicleStyle
- case .neon:
- neonStyle
- case .ink:
- inkStyle
- case .prism:
- prismStyle
- case .tape:
- tapeStyle
- case .morph:
- morphStyle
- case .stack:
- stackStyle
- case .wave:
- waveStyle
- case .pattern:
- patternStyle
- case .leather:
- leatherStyle
- case .glass:
- glassStyle
- case .motion:
- motionStyle
- case .micro:
- microStyle
+ Group {
+ switch dayViewStyle {
+ case .classic:
+ classicStyle
+ case .minimal:
+ minimalStyle
+ case .compact:
+ compactStyle
+ case .bubble:
+ bubbleStyle
+ case .grid:
+ gridStyle
+ case .aura:
+ auraStyle
+ case .chronicle:
+ chronicleStyle
+ case .neon:
+ neonStyle
+ case .ink:
+ inkStyle
+ case .prism:
+ prismStyle
+ case .tape:
+ tapeStyle
+ case .morph:
+ morphStyle
+ case .stack:
+ stackStyle
+ case .wave:
+ waveStyle
+ case .pattern:
+ patternStyle
+ case .leather:
+ leatherStyle
+ case .glass:
+ glassStyle
+ case .motion:
+ motionStyle
+ case .micro:
+ microStyle
+ }
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(accessibilityDescription)
+ .accessibilityHint(isMissing ? String(localized: "Tap to log mood for this day") : String(localized: "Tap to view or edit"))
+ .accessibilityAddTraits(.isButton)
+ }
+
+ private var accessibilityDescription: String {
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateStyle = .full
+ let dateString = dateFormatter.string(from: entry.forDate)
+
+ if isMissing {
+ return String(localized: "\(dateString), no mood logged")
+ } else {
+ return "\(dateString), \(entry.mood.strValue)"
}
}
diff --git a/Shared/Views/FeelsSubscriptionStoreView.swift b/Shared/Views/FeelsSubscriptionStoreView.swift
index f4996bc..388e829 100644
--- a/Shared/Views/FeelsSubscriptionStoreView.swift
+++ b/Shared/Views/FeelsSubscriptionStoreView.swift
@@ -52,8 +52,8 @@ struct FeelsSubscriptionStoreView: View {
VStack(alignment: .leading, spacing: 12) {
FeatureHighlight(icon: "calendar", text: "Month & Year Views")
FeatureHighlight(icon: "lightbulb.fill", text: "AI-Powered Insights")
- FeatureHighlight(icon: "photo.fill", text: "Photos & Journal Notes")
- FeatureHighlight(icon: "heart.fill", text: "Health Data Correlation")
+ FeatureHighlight(icon: "heart.text.square.fill", text: "Health Data Correlation")
+ FeatureHighlight(icon: "square.grid.2x2.fill", text: "Interactive Widgets")
}
.padding(.top, 8)
}
diff --git a/Shared/Views/InsightsView/InsightsView.swift b/Shared/Views/InsightsView/InsightsView.swift
index e39a06e..5f251e6 100644
--- a/Shared/Views/InsightsView/InsightsView.swift
+++ b/Shared/Views/InsightsView/InsightsView.swift
@@ -101,26 +101,73 @@ struct InsightsView: View {
.disabled(iapManager.shouldShowPaywall)
if iapManager.shouldShowPaywall {
- Color.black.opacity(0.3)
- .ignoresSafeArea()
- .onTapGesture {
- showSubscriptionStore = true
+ // Premium insights prompt
+ VStack(spacing: 24) {
+ Spacer()
+
+ // Icon
+ ZStack {
+ Circle()
+ .fill(
+ LinearGradient(
+ colors: [.purple.opacity(0.2), .blue.opacity(0.2)],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
+ .frame(width: 100, height: 100)
+
+ Image(systemName: "sparkles")
+ .font(.system(size: 44))
+ .foregroundStyle(
+ LinearGradient(
+ colors: [.purple, .blue],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
}
- VStack {
- Spacer()
+ // Text
+ VStack(spacing: 12) {
+ Text("Unlock AI-Powered Insights")
+ .font(.system(size: 24, weight: .bold, design: .rounded))
+ .foregroundColor(textColor)
+ .multilineTextAlignment(.center)
+
+ Text("Discover patterns in your mood, get personalized recommendations, and understand what affects how you feel.")
+ .font(.system(size: 16))
+ .foregroundColor(textColor.opacity(0.7))
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 32)
+ }
+
+ // Subscribe button
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))
+ HStack {
+ Image(systemName: "sparkles")
+ Text("Get Personal Insights")
+ }
+ .font(.system(size: 18, weight: .bold))
+ .foregroundColor(.white)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 16)
+ .background(
+ LinearGradient(
+ colors: [.purple, .blue],
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 14))
}
- .padding()
+ .padding(.horizontal, 24)
+
+ Spacer()
}
+ .background(theme.currentTheme.bg)
}
}
.sheet(isPresented: $showSubscriptionStore) {
diff --git a/Shared/Views/MonthView/MonthView.swift b/Shared/Views/MonthView/MonthView.swift
index 6592b69..2162272 100644
--- a/Shared/Views/MonthView/MonthView.swift
+++ b/Shared/Views/MonthView/MonthView.swift
@@ -43,6 +43,24 @@ struct MonthView: View {
@State private var trialWarningHidden = false
@State private var showSubscriptionStore = false
+ /// Filters month data to only current month when subscription/trial expired
+ private var filteredMonthData: [Int: [Int: [MoodEntryModel]]] {
+ guard iapManager.shouldShowPaywall else {
+ return viewModel.grouped
+ }
+
+ // Only show current month when paywall should show
+ let currentMonth = Calendar.current.component(.month, from: Date())
+ let currentYear = Calendar.current.component(.year, from: Date())
+
+ var filtered: [Int: [Int: [MoodEntryModel]]] = [:]
+ if let yearData = viewModel.grouped[currentYear],
+ let monthData = yearData[currentMonth] {
+ filtered[currentYear] = [currentMonth: monthData]
+ }
+ return filtered
+ }
+
var body: some View {
ZStack {
if viewModel.hasNoData {
@@ -51,7 +69,7 @@ struct MonthView: View {
} else {
ScrollView {
VStack(spacing: 16) {
- ForEach(viewModel.grouped.sorted(by: { $0.key > $1.key }), id: \.key) { year, months in
+ ForEach(filteredMonthData.sorted(by: { $0.key > $1.key }), id: \.key) { year, months in
// for each month
ForEach(months.sorted(by: { $0.key > $1.key }), id: \.key) { month, entries in
MonthCard(
@@ -90,7 +108,7 @@ struct MonthView: View {
}
)
}
- .disabled(iapManager.shouldShowPaywall)
+ .scrollDisabled(iapManager.shouldShowPaywall)
}
// Hidden text to trigger updates when custom tint changes
diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift
index 2536d90..1c387ab 100644
--- a/Shared/Views/SettingsView/SettingsView.swift
+++ b/Shared/Views/SettingsView/SettingsView.swift
@@ -13,12 +13,18 @@ import TipKit
// MARK: - Settings Content View (for use in SettingsTabView)
struct SettingsContentView: View {
@EnvironmentObject var authManager: BiometricAuthManager
+ @EnvironmentObject var iapManager: IAPManager
@State private var showOnboarding = false
@State private var showExportView = false
@State private var showReminderTimePicker = false
+ @State private var showSubscriptionStore = false
+ @State private var showTrialDatePicker = false
@StateObject private var healthService = HealthService.shared
+ @AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults)
+ private var firstLaunchDate = Date()
+
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
@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
@@ -43,6 +49,12 @@ struct SettingsContentView: View {
eulaButton
privacyButton
+ #if DEBUG
+ // Debug section
+ debugSectionHeader
+ trialDateButton
+ #endif
+
Spacer()
.frame(height: 20)
@@ -107,6 +119,9 @@ struct SettingsContentView: View {
}
.padding()
})
+ .accessibilityLabel(String(localized: "Reminder Time"))
+ .accessibilityValue(formattedReminderTime)
+ .accessibilityHint(String(localized: "Opens time picker to change reminder time"))
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
@@ -156,6 +171,79 @@ struct SettingsContentView: View {
.padding(.horizontal, 4)
}
+ // MARK: - Debug Section
+
+ #if DEBUG
+ private var debugSectionHeader: some View {
+ HStack {
+ Text("Debug")
+ .font(.headline)
+ .foregroundColor(.red)
+ Spacer()
+ }
+ .padding(.top, 20)
+ .padding(.horizontal, 4)
+ }
+
+ private var trialDateButton: some View {
+ ZStack {
+ theme.currentTheme.secondaryBGColor
+ VStack(spacing: 12) {
+ HStack(spacing: 12) {
+ Image(systemName: "calendar.badge.clock")
+ .font(.title2)
+ .foregroundColor(.orange)
+ .frame(width: 32)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Trial Start Date")
+ .foregroundColor(textColor)
+
+ Text("Current: \(firstLaunchDate.formatted(date: .abbreviated, time: .omitted))")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Button("Change") {
+ showTrialDatePicker = true
+ }
+ .font(.subheadline.weight(.medium))
+ }
+ .padding()
+ }
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
+ .sheet(isPresented: $showTrialDatePicker) {
+ NavigationStack {
+ DatePicker(
+ "Trial Start Date",
+ selection: $firstLaunchDate,
+ displayedComponents: .date
+ )
+ .datePickerStyle(.graphical)
+ .padding()
+ .navigationTitle("Set Trial Start Date")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") {
+ showTrialDatePicker = false
+ // Refresh subscription state
+ Task {
+ await iapManager.checkSubscriptionStatus()
+ }
+ }
+ }
+ }
+ }
+ .presentationDetents([.medium])
+ }
+ }
+ #endif
+
// MARK: - Privacy Lock Toggle
@ViewBuilder
@@ -196,6 +284,8 @@ struct SettingsContentView: View {
}
))
.labelsHidden()
+ .accessibilityLabel(String(localized: "Privacy Lock"))
+ .accessibilityHint(String(localized: "Require biometric authentication to open app"))
}
.padding()
}
@@ -228,9 +318,16 @@ struct SettingsContentView: View {
Spacer()
if healthService.isAvailable {
+ // Disable toggle and force off when paywall should show
Toggle("", isOn: Binding(
- get: { healthService.isEnabled },
+ get: { iapManager.shouldShowPaywall ? false : healthService.isEnabled },
set: { newValue in
+ // If paywall should show, show subscription store instead
+ if iapManager.shouldShowPaywall {
+ showSubscriptionStore = true
+ return
+ }
+
if newValue {
Task {
// Request all permissions in a single dialog
@@ -257,20 +354,40 @@ struct SettingsContentView: View {
}
))
.labelsHidden()
+ .disabled(iapManager.shouldShowPaywall)
+ .accessibilityLabel(String(localized: "Apple Health"))
+ .accessibilityHint(String(localized: "Sync mood data with Apple Health"))
} else {
Text("Not Available")
.font(.caption)
.foregroundStyle(.secondary)
+ .accessibilityLabel(String(localized: "Apple Health not available"))
}
}
.padding()
+ // Show premium badge when paywall should show
+ if iapManager.shouldShowPaywall {
+ HStack {
+ Image(systemName: "crown.fill")
+ .foregroundColor(.yellow)
+ Text("Premium Feature")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.horizontal)
+ .padding(.bottom, 12)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(String(localized: "Premium feature, subscription required"))
+ }
// Show sync progress or status
- if healthKitManager.isSyncing || !healthKitManager.syncStatus.isEmpty {
+ else if healthKitManager.isSyncing || !healthKitManager.syncStatus.isEmpty {
VStack(spacing: 4) {
if healthKitManager.isSyncing {
ProgressView(value: healthKitManager.syncProgress)
.tint(.red)
+ .accessibilityLabel(String(localized: "Syncing health data"))
+ .accessibilityValue("\(Int(healthKitManager.syncProgress * 100)) percent")
}
Text(healthKitManager.syncStatus)
.font(.caption)
@@ -283,6 +400,9 @@ struct SettingsContentView: View {
.background(theme.currentTheme.secondaryBGColor)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
.healthKitSyncTip()
+ .sheet(isPresented: $showSubscriptionStore) {
+ FeelsSubscriptionStoreView()
+ }
}
// MARK: - Export Data Button
@@ -317,6 +437,8 @@ struct SettingsContentView: View {
}
.padding()
})
+ .accessibilityLabel(String(localized: "Export Data"))
+ .accessibilityHint(String(localized: "Export your mood data as CSV or PDF"))
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
@@ -332,6 +454,7 @@ struct SettingsContentView: View {
Text(String(localized: "settings_view_show_onboarding"))
.foregroundColor(textColor)
})
+ .accessibilityHint(String(localized: "View the app introduction again"))
.padding()
}
.fixedSize(horizontal: false, vertical: true)
@@ -348,6 +471,7 @@ struct SettingsContentView: View {
EventLogger.log(event: "toggle_can_delete", withData: ["value": newValue])
}
.foregroundColor(textColor)
+ .accessibilityHint(String(localized: "Allow deleting mood entries by swiping"))
.padding()
}
}
@@ -367,6 +491,7 @@ struct SettingsContentView: View {
Text(String(localized: "settings_view_show_eula"))
.foregroundColor(textColor)
})
+ .accessibilityHint(String(localized: "Opens End User License Agreement in browser"))
.padding()
}
.fixedSize(horizontal: false, vertical: true)
@@ -385,6 +510,7 @@ struct SettingsContentView: View {
Text(String(localized: "settings_view_show_privacy"))
.foregroundColor(textColor)
})
+ .accessibilityHint(String(localized: "Opens Privacy Policy in browser"))
.padding()
}
.fixedSize(horizontal: false, vertical: true)
@@ -467,6 +593,8 @@ struct SettingsView: View {
@State private var showSpecialThanks = false
@State private var showWhyBGMode = false
+ @State private var showSubscriptionStore = false
+ @State private var showTrialDatePicker = false
@StateObject private var healthService = HealthService.shared
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
@@ -712,9 +840,16 @@ struct SettingsView: View {
Spacer()
if healthService.isAvailable {
+ // Disable toggle and force off when paywall should show
Toggle("", isOn: Binding(
- get: { healthService.isEnabled },
+ get: { iapManager.shouldShowPaywall ? false : healthService.isEnabled },
set: { newValue in
+ // If paywall should show, show subscription store instead
+ if iapManager.shouldShowPaywall {
+ showSubscriptionStore = true
+ return
+ }
+
if newValue {
Task {
// Request all permissions in a single dialog
@@ -741,6 +876,7 @@ struct SettingsView: View {
}
))
.labelsHidden()
+ .disabled(iapManager.shouldShowPaywall)
} else {
Text("Not Available")
.font(.caption)
@@ -749,8 +885,20 @@ struct SettingsView: View {
}
.padding()
+ // Show premium badge when paywall should show
+ if iapManager.shouldShowPaywall {
+ HStack {
+ Image(systemName: "crown.fill")
+ .foregroundColor(.yellow)
+ Text("Premium Feature")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.horizontal)
+ .padding(.bottom, 12)
+ }
// Show sync progress or status
- if healthKitManager.isSyncing || !healthKitManager.syncStatus.isEmpty {
+ else if healthKitManager.isSyncing || !healthKitManager.syncStatus.isEmpty {
VStack(spacing: 4) {
if healthKitManager.isSyncing {
ProgressView(value: healthKitManager.syncProgress)
@@ -766,6 +914,9 @@ struct SettingsView: View {
}
.background(theme.currentTheme.secondaryBGColor)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
+ .sheet(isPresented: $showSubscriptionStore) {
+ FeelsSubscriptionStoreView()
+ }
}
// MARK: - Export Data Button
@@ -870,24 +1021,57 @@ struct SettingsView: View {
private var editFirstLaunchDatePast: some View {
ZStack {
theme.currentTheme.secondaryBGColor
- Button(action: {
- var tmpDate = Date()
- tmpDate = Calendar.current.date(byAdding: .day, value: -29, to: tmpDate)!
- tmpDate = Calendar.current.date(byAdding: .hour, value: -23, to: tmpDate)!
- tmpDate = Calendar.current.date(byAdding: .minute, value: -59, to: tmpDate)!
- tmpDate = Calendar.current.date(byAdding: .second, value: -45, to: tmpDate)!
- firstLaunchDate = tmpDate
- Task {
- await iapManager.checkSubscriptionStatus()
+ HStack(spacing: 12) {
+ Image(systemName: "calendar.badge.clock")
+ .font(.title2)
+ .foregroundColor(.orange)
+ .frame(width: 32)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Trial Start Date")
+ .foregroundColor(textColor)
+
+ Text("Current: \(firstLaunchDate.formatted(date: .abbreviated, time: .omitted))")
+ .font(.caption)
+ .foregroundStyle(.secondary)
}
- }, label: {
- Text("Set first launch date back 29 days, 23 hrs, 45 seconds")
- .foregroundColor(textColor)
- })
+
+ Spacer()
+
+ Button("Change") {
+ showTrialDatePicker = true
+ }
+ .font(.subheadline.weight(.medium))
+ }
.padding()
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
+ .sheet(isPresented: $showTrialDatePicker) {
+ NavigationStack {
+ DatePicker(
+ "Trial Start Date",
+ selection: $firstLaunchDate,
+ displayedComponents: .date
+ )
+ .datePickerStyle(.graphical)
+ .padding()
+ .navigationTitle("Set Trial Start Date")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") {
+ showTrialDatePicker = false
+ // Refresh subscription state
+ Task {
+ await iapManager.checkSubscriptionStatus()
+ }
+ }
+ }
+ }
+ }
+ .presentationDetents([.medium])
+ }
}
private var resetLaunchDate: some View {
diff --git a/Shared/Views/YearView/YearView.swift b/Shared/Views/YearView/YearView.swift
index 3073ea9..2257f68 100644
--- a/Shared/Views/YearView/YearView.swift
+++ b/Shared/Views/YearView/YearView.swift
@@ -64,27 +64,42 @@ struct YearView: View {
}
)
}
- .disabled(iapManager.shouldShowPaywall)
+ .scrollDisabled(iapManager.shouldShowPaywall)
+ .mask(
+ // Fade effect when paywall should show: 100% at top, 0% at bottom
+ iapManager.shouldShowPaywall ?
+ AnyView(
+ LinearGradient(
+ gradient: Gradient(stops: [
+ .init(color: .black, location: 0),
+ .init(color: .black, location: 0.3),
+ .init(color: .clear, location: 1.0)
+ ]),
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ ) : AnyView(Color.black)
+ )
}
if iapManager.shouldShowPaywall {
- Color.black.opacity(0.3)
- .ignoresSafeArea()
- .onTapGesture {
- showSubscriptionStore = true
- }
-
VStack {
Spacer()
- Button {
- showSubscriptionStore = true
- } label: {
- Text(String(localized: "subscription_required_button"))
+ VStack(spacing: 16) {
+ Text("Subscribe to see your full year")
.font(.headline)
- .foregroundColor(.white)
- .frame(maxWidth: .infinity)
- .padding()
- .background(RoundedRectangle(cornerRadius: 10).fill(Color.pink))
+ .foregroundColor(textColor)
+
+ 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()
}