diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index d7d5dc8..0ecbc8e 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -37,7 +37,12 @@
"Bash(for:*)",
"Bash(# Check Button and Label strings grep -rE ''\\(Button|Label|navigationTitle\\)\\\\\\(\"\"'' /Users/treyt/Desktop/code/Feels/Shared/ --include=\"\"*.swift\"\")",
"Bash(# Double-check a few of these strings to make sure they''re not used echo \"\"=== Checking ''Custom'' ===\"\" grep -rn ''\"\"Custom\"\"'' /Users/treyt/Desktop/code/Feels/Shared/ --include=\"\"*.swift\"\")",
- "Bash(echo \"=== How ''3D card flip'' is used ===\" grep -rn \"3D card flip\" /Users/treyt/Desktop/code/Feels/Shared/ --include=\"*.swift\")"
+ "Bash(echo \"=== How ''3D card flip'' is used ===\" grep -rn \"3D card flip\" /Users/treyt/Desktop/code/Feels/Shared/ --include=\"*.swift\")",
+ "Bash(ffprobe:*)",
+ "Bash(npx remotion:*)",
+ "Bash(npx tsc:*)",
+ "Bash(npm start)",
+ "Bash(npm run:*)"
],
"ask": [
"Bash(git commit:*)",
diff --git a/Configuration.storekit b/Configuration.storekit
index ca14604..a3e76a1 100644
--- a/Configuration.storekit
+++ b/Configuration.storekit
@@ -67,7 +67,7 @@
},
"subscriptionGroups" : [
{
- "id" : "2CFE4C4F",
+ "id" : "21914363",
"localizations" : [
],
@@ -92,10 +92,10 @@
"locale" : "en_US"
}
],
- "productID" : "com.tt.feels.IAP.subscriptions.monthly",
+ "productID" : "com.88oakapps.feels.IAP.subscriptions.monthly",
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Monthly",
- "subscriptionGroupID" : "2CFE4C4F",
+ "subscriptionGroupID" : "21914363",
"type" : "RecurringSubscription",
"winbackOffers" : [
@@ -120,10 +120,10 @@
"locale" : "en_US"
}
],
- "productID" : "com.tt.feels.IAP.subscriptions.yearly",
+ "productID" : "com.88oakapps.feels.IAP.subscriptions.yearly",
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "Yearly",
- "subscriptionGroupID" : "2CFE4C4F",
+ "subscriptionGroupID" : "21914363",
"type" : "RecurringSubscription",
"winbackOffers" : [
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 4a9f459..7159e0a 100644
--- a/Feels/Localizable.xcstrings
+++ b/Feels/Localizable.xcstrings
@@ -997,6 +997,9 @@
}
}
}
+ },
+ "%lld DAYS TRACKED" : {
+
},
"%lld entries" : {
"comment" : "A label showing the total number of mood entries recorded. The argument is the total number of entries.",
@@ -1039,6 +1042,9 @@
}
}
}
+ },
+ "%lld moods" : {
+
},
"%lld percent" : {
"comment" : "A value indicating the percentage of health data that has been successfully synced with Apple Health.",
@@ -1213,6 +1219,9 @@
}
}
}
+ },
+ "→" : {
+
},
">" : {
"comment" : "A symbol that appears before a command in a terminal interface.",
@@ -1543,6 +1552,9 @@
}
}
}
+ },
+ "10 DAYS" : {
+
},
"12" : {
"localizations" : {
@@ -2200,6 +2212,9 @@
"All styles & complications" : {
"comment" : "A description of what the \"Export Watch Screenshots\" button does.",
"isCommentAutoGenerated" : true
+ },
+ "All Time Moods" : {
+
},
"Allow deleting mood entries by swiping" : {
"comment" : "A hint describing the functionality of the \"Allow deleting mood entries by swiping\" toggle.",
@@ -4836,6 +4851,9 @@
}
}
}
+ },
+ "DAYS" : {
+
},
"Days Tracked" : {
"comment" : "A label displayed below the number of days a user has tracked their mood.",
@@ -6279,6 +6297,9 @@
}
}
}
+ },
+ "entries tracked" : {
+
},
"Entry Details" : {
"comment" : "The title of the view that displays detailed information about a mood entry.",
@@ -6833,11 +6854,11 @@
}
},
"Export Voting Layouts" : {
- "comment" : "A button label that triggers the export of all voting layout configurations.",
+ "comment" : "A button label that allows users to export all voting layout configurations.",
"isCommentAutoGenerated" : true
},
"Export Watch Screenshots" : {
- "comment" : "A button label that allows users to export all watch face and complication previews to a file.",
+ "comment" : "A button label that allows users to export watch view screenshots.",
"isCommentAutoGenerated" : true
},
"Export Widget Screenshots" : {
@@ -7262,6 +7283,10 @@
}
}
},
+ "Fill 2 years data + export PNGs" : {
+ "comment" : "A description of the action to generate and export sharing screenshots.",
+ "isCommentAutoGenerated" : true
+ },
"Find Your\nInner Calm" : {
"comment" : "A title describing the main benefit of the premium subscription.",
"isCommentAutoGenerated" : true,
@@ -7468,6 +7493,10 @@
}
}
},
+ "Generate & Export Sharing" : {
+ "comment" : "A button that, when tapped, generates and exports all sharing screenshots.",
+ "isCommentAutoGenerated" : true
+ },
"Get Mood Streak" : {
"comment" : "Title of an intent that checks the user's current mood logging streak.",
"isCommentAutoGenerated" : true,
@@ -8706,6 +8735,12 @@
}
}
}
+ },
+ "Longest Streak" : {
+
+ },
+ "LONGEST STREAK" : {
+
},
"Make Tracking\nFun Again!" : {
"localizations" : {
@@ -9669,6 +9704,10 @@
}
}
},
+ "No designs available" : {
+ "comment" : "A message displayed when there are no sharing design variations available.",
+ "isCommentAutoGenerated" : true
+ },
"No entry" : {
"comment" : "A label indicating that there is no entry for a particular day.",
"isCommentAutoGenerated" : true,
@@ -9878,6 +9917,9 @@
}
}
}
+ },
+ "of feeling" : {
+
},
"OK" : {
"comment" : "The text for an OK button.",
@@ -13294,6 +13336,10 @@
"comment" : "A description of where the insights export file will be saved.",
"isCommentAutoGenerated" : true
},
+ "Saved to Documents/SharingExports" : {
+ "comment" : "A label indicating where the generated sharing screenshots are saved.",
+ "isCommentAutoGenerated" : true
+ },
"Saved to Documents/VotingLayoutExports" : {
"comment" : "A description of where the voting layouts are saved when exported.",
"isCommentAutoGenerated" : true
@@ -18031,6 +18077,9 @@
}
}
}
+ },
+ "Your recent moods" : {
+
}
},
"version" : "1.1"
diff --git a/Shared/Assets.xcassets/FeelsAppIcon.imageset/Contents.json b/Shared/Assets.xcassets/FeelsAppIcon.imageset/Contents.json
new file mode 100644
index 0000000..a6ae92c
--- /dev/null
+++ b/Shared/Assets.xcassets/FeelsAppIcon.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "FeelsAppIcon.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Shared/Assets.xcassets/FeelsAppIcon.imageset/FeelsAppIcon.png b/Shared/Assets.xcassets/FeelsAppIcon.imageset/FeelsAppIcon.png
new file mode 100644
index 0000000..c440a16
Binary files /dev/null and b/Shared/Assets.xcassets/FeelsAppIcon.imageset/FeelsAppIcon.png differ
diff --git a/Shared/IAPManager.swift b/Shared/IAPManager.swift
index 4c1abcd..60bf02b 100644
--- a/Shared/IAPManager.swift
+++ b/Shared/IAPManager.swift
@@ -42,11 +42,11 @@ class IAPManager: ObservableObject {
// MARK: - Constants
- static let subscriptionGroupID = "2CFE4C4F"
+ static let subscriptionGroupID = "21914363"
private let productIdentifiers: Set = [
- "com.tt.feels.IAP.subscriptions.monthly",
- "com.tt.feels.IAP.subscriptions.yearly"
+ "com.88oakapps.feels.IAP.subscriptions.monthly",
+ "com.88oakapps.feels.IAP.subscriptions.yearly"
]
private let trialDays = 30
diff --git a/Shared/Persisence/DataControllerHelper.swift b/Shared/Persisence/DataControllerHelper.swift
index 3001b9f..60c5161 100644
--- a/Shared/Persisence/DataControllerHelper.swift
+++ b/Shared/Persisence/DataControllerHelper.swift
@@ -79,6 +79,29 @@ extension DataController {
saveAndRunDataListeners()
}
+ #if DEBUG
+ func populate2YearsData() {
+ clearDB()
+
+ for idx in 1...730 {
+ let date = Calendar.current.date(byAdding: .day, value: -idx, to: Date())!
+ var moodValue = Int.random(in: 3...4)
+ if Int.random(in: 0...400) % 5 == 0 {
+ moodValue = Int.random(in: 0...4)
+ }
+
+ let entry = MoodEntryModel(
+ forDate: date,
+ mood: Mood(rawValue: moodValue) ?? .average,
+ entryType: .listView
+ )
+ modelContext.insert(entry)
+ }
+
+ saveAndRunDataListeners()
+ }
+ #endif
+
func longestStreak() -> [MoodEntryModel] {
let descriptor = FetchDescriptor(
sortBy: [SortDescriptor(\.forDate, order: .forward)]
diff --git a/Shared/Random.swift b/Shared/Random.swift
index 36e4dc7..0608f90 100644
--- a/Shared/Random.swift
+++ b/Shared/Random.swift
@@ -136,8 +136,11 @@ extension View {
}
func asImage(size: CGSize) -> UIImage {
- let controller = UIHostingController(rootView: self)
+ let wrapped = self.ignoresSafeArea().frame(width: size.width, height: size.height)
+ let controller = UIHostingController(rootView: wrapped)
controller.view.bounds = CGRect(origin: .zero, size: size)
+ controller.view.backgroundColor = .clear
+ controller.view.layoutIfNeeded()
let image = controller.view.asImage()
return image
}
diff --git a/Shared/Services/SharingScreenshotExporter.swift b/Shared/Services/SharingScreenshotExporter.swift
new file mode 100644
index 0000000..5f7ca32
--- /dev/null
+++ b/Shared/Services/SharingScreenshotExporter.swift
@@ -0,0 +1,176 @@
+//
+// SharingScreenshotExporter.swift
+// Feels
+//
+// Debug utility to export sharing template screenshots.
+//
+
+#if DEBUG
+import SwiftUI
+import UIKit
+
+/// Exports sharing template screenshots for App Store marketing
+@MainActor
+class SharingScreenshotExporter {
+
+ /// Exports all original templates + kept variations as PNGs
+ /// - Returns: URL to the export directory, or nil if failed
+ static func exportAllSharingScreenshots() async -> URL? {
+ let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
+ let exportPath = documentsPath.appendingPathComponent("SharingExports", isDirectory: true)
+
+ // Clean and create export directory
+ try? FileManager.default.removeItem(at: exportPath)
+ try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
+
+ // Create subdirectories
+ let origDir = exportPath.appendingPathComponent("originals", isDirectory: true)
+ let varDir = exportPath.appendingPathComponent("variations", isDirectory: true)
+ try? FileManager.default.createDirectory(at: origDir, withIntermediateDirectories: true)
+ try? FileManager.default.createDirectory(at: varDir, withIntermediateDirectories: true)
+
+ var totalExported = 0
+ let distantPast = Date(timeIntervalSince1970: 0)
+ let now = Date()
+ let calendar = Calendar.current
+ // ──────────────────────────────────────────────
+ // Fetch shared data once for all variations
+ // ──────────────────────────────────────────────
+
+ // All moods data
+ let allEntries = DataController.shared.getData(
+ startDate: distantPast, endDate: now, includedDays: [1,2,3,4,5,6,7]
+ )
+ let allMetrics = Random.createTotalPerc(fromEntries: allEntries)
+ .sorted(by: { $0.mood.rawValue > $1.mood.rawValue })
+
+ // Current streak data (last 10 days) — fetch 12 days to handle boundary, take last 10
+ let streakFetchStart = calendar.date(byAdding: .day, value: -12, to: now)!
+ let streakEntries = Array(DataController.shared.getData(
+ startDate: streakFetchStart, endDate: now, includedDays: [1,2,3,4,5,6,7]
+ ).suffix(10))
+
+ // Current month data
+ let currentMonthEntries = DataController.shared.getData(
+ startDate: now.startOfMonth, endDate: now.endOfMonth, includedDays: [1,2,3,4,5,6,7]
+ )
+ let currentMonthMetrics = Random.createTotalPerc(fromEntries: currentMonthEntries)
+ .sorted(by: { $0.mood.rawValue > $1.mood.rawValue })
+ let currentMonth = calendar.component(.month, from: now)
+
+ // Last month data
+ let lastMonthDate = calendar.date(byAdding: .month, value: -1, to: now)!
+ let lastMonthStart = lastMonthDate.startOfMonth
+ let lastMonthEnd = lastMonthDate.endOfMonth
+ let lastMonthEntries = DataController.shared.getData(
+ startDate: lastMonthStart, endDate: lastMonthEnd, includedDays: [1,2,3,4,5,6,7]
+ )
+ let lastMonthMetrics = Random.createTotalPerc(fromEntries: lastMonthEntries)
+ .sorted(by: { $0.mood.rawValue > $1.mood.rawValue })
+ let lastMonth = calendar.component(.month, from: lastMonthDate)
+
+ // Longest streak data (find longest "great" streak)
+ let selectedMood: Mood = .great
+ let longestStreakEntries: [MoodEntryModel] = {
+ var splitArrays = [[MoodEntryModel]]()
+ var currentSplit = [MoodEntryModel]()
+ for entry in allEntries {
+ if entry.mood == selectedMood {
+ currentSplit.append(entry)
+ } else {
+ splitArrays.append(currentSplit)
+ currentSplit.removeAll()
+ }
+ }
+ splitArrays.append(currentSplit)
+ return splitArrays.sorted(by: { $0.count > $1.count }).first ?? []
+ }()
+
+ // ──────────────────────────────────────────────
+ // Export originals
+ // ──────────────────────────────────────────────
+
+ let allMoods = AllMoodsTotalTemplate(isPreview: false, startDate: distantPast, endDate: now, fakeData: false)
+ if saveImage(allMoods.image, to: origDir, name: "all_moods_total") { totalExported += 1 }
+
+ let currentStreak = CurrentStreakTemplate(isPreview: false, startDate: streakFetchStart, endDate: now, fakeData: false)
+ if saveImage(currentStreak.image, to: origDir, name: "current_streak") { totalExported += 1 }
+
+ let monthTotal = MonthTotalTemplate(isPreview: false, startDate: now.startOfMonth, endDate: now.endOfMonth, fakeData: false)
+ if saveImage(monthTotal.image, to: origDir, name: "month_total") { totalExported += 1 }
+
+ let longestStreak = LongestStreakTemplate(isPreview: false, startDate: distantPast, endDate: now, fakeData: false)
+ if saveImage(longestStreak.image, to: origDir, name: "longest_streak") { totalExported += 1 }
+
+ // ──────────────────────────────────────────────
+ // Export All Moods variations (666x1190)
+ // Kept: V2, V5
+ // ──────────────────────────────────────────────
+
+ if saveImage(AllMoodsV2(metrics: allMetrics, totalCount: allEntries.count).image,
+ to: varDir, name: "all_moods_v2_gradient") { totalExported += 1 }
+
+ if saveImage(AllMoodsV5(metrics: allMetrics, totalCount: allEntries.count).image,
+ to: varDir, name: "all_moods_v5_colorblock") { totalExported += 1 }
+
+ // ──────────────────────────────────────────────
+ // Export Current Streak variations (666x1190)
+ // Kept: V2, V5
+ // ──────────────────────────────────────────────
+
+ if saveImage(CurrentStreakV2(moodEntries: streakEntries).image,
+ to: varDir, name: "current_streak_v2_gradient") { totalExported += 1 }
+
+ if saveImage(CurrentStreakV5(moodEntries: streakEntries).image,
+ to: varDir, name: "current_streak_v5_colorblock") { totalExported += 1 }
+
+ // ──────────────────────────────────────────────
+ // Export Month Total variations (666x1190)
+ // Kept: V1, V5 — exported for BOTH current and last month
+ // ──────────────────────────────────────────────
+
+ // Current month
+ if saveImage(MonthTotalV1(moodMetrics: currentMonthMetrics, moodEntries: currentMonthEntries, month: currentMonth).image,
+ to: varDir, name: "month_total_v1_clean_current") { totalExported += 1 }
+
+ if saveImage(MonthTotalV5(moodMetrics: currentMonthMetrics, moodEntries: currentMonthEntries, month: currentMonth).image,
+ to: varDir, name: "month_total_v5_colorblock_current") { totalExported += 1 }
+
+ // Last month
+ if saveImage(MonthTotalV1(moodMetrics: lastMonthMetrics, moodEntries: lastMonthEntries, month: lastMonth).image,
+ to: varDir, name: "month_total_v1_clean_lastmonth") { totalExported += 1 }
+
+ if saveImage(MonthTotalV5(moodMetrics: lastMonthMetrics, moodEntries: lastMonthEntries, month: lastMonth).image,
+ to: varDir, name: "month_total_v5_colorblock_lastmonth") { totalExported += 1 }
+
+ // ──────────────────────────────────────────────
+ // Export Longest Streak variations (650x400)
+ // Kept: V2, V3
+ // ──────────────────────────────────────────────
+
+ if saveImage(LongestStreakV2(streakEntries: longestStreakEntries, selectedMood: selectedMood).image,
+ to: varDir, name: "longest_streak_v2_gradient") { totalExported += 1 }
+
+ if saveImage(LongestStreakV3(streakEntries: longestStreakEntries, selectedMood: selectedMood).image,
+ to: varDir, name: "longest_streak_v3_dark") { totalExported += 1 }
+
+ print("📸 Total \(totalExported) sharing screenshots exported to: \(exportPath.path)")
+ print(" originals/ — 4 original templates")
+ print(" variations/ — 12 design variations (2 all moods, 2 current streak, 4 month total, 2 longest streak)")
+ return exportPath
+ }
+
+ private static func saveImage(_ image: UIImage, to folder: URL, name: String) -> Bool {
+ let url = folder.appendingPathComponent("\(name).png")
+ if let data = image.pngData() {
+ do {
+ try data.write(to: url)
+ return true
+ } catch {
+ print("Failed to save \(name): \(error)")
+ }
+ }
+ return false
+ }
+}
+#endif
diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift
index 05e0e09..d0181fc 100644
--- a/Shared/Views/SettingsView/SettingsView.swift
+++ b/Shared/Views/SettingsView/SettingsView.swift
@@ -27,6 +27,8 @@ struct SettingsContentView: View {
@State private var watchExportPath: URL?
@State private var isExportingInsights = false
@State private var insightsExportPath: URL?
+ @State private var isGeneratingScreenshots = false
+ @State private var sharingExportPath: URL?
@State private var isDeletingHealthKitData = false
@State private var healthKitDeleteResult: String?
@StateObject private var healthService = HealthService.shared
@@ -73,6 +75,7 @@ struct SettingsContentView: View {
exportVotingLayoutsButton
exportWatchViewsButton
exportInsightsButton
+ generateAndExportButton
deleteHealthKitDataButton
clearDataButton
@@ -654,6 +657,67 @@ struct SettingsContentView: View {
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
+ private var generateAndExportButton: some View {
+ ZStack {
+ theme.currentTheme.secondaryBGColor
+ Button {
+ isGeneratingScreenshots = true
+ Task {
+ DataController.shared.populate2YearsData()
+ sharingExportPath = await SharingScreenshotExporter.exportAllSharingScreenshots()
+ isGeneratingScreenshots = false
+ if let path = sharingExportPath {
+ print("📸 Sharing screenshots exported to: \(path.path)")
+ openInFilesApp(path)
+ }
+ }
+ } label: {
+ HStack(spacing: 12) {
+ if isGeneratingScreenshots {
+ ProgressView()
+ .frame(width: 32)
+ } else {
+ Image(systemName: "photo.on.rectangle.angled")
+ .font(.title2)
+ .foregroundStyle(
+ LinearGradient(
+ colors: [.green, .blue],
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ )
+ .frame(width: 32)
+ }
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Generate & Export Sharing")
+ .foregroundColor(textColor)
+
+ if let path = sharingExportPath {
+ Text("Saved to Documents/SharingExports")
+ .font(.caption)
+ .foregroundColor(.green)
+ } else {
+ Text("Fill 2 years data + export PNGs")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Spacer()
+
+ Image(systemName: "arrow.down.doc.fill")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ .padding()
+ }
+ .disabled(isGeneratingScreenshots)
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
+ }
+
private var deleteHealthKitDataButton: some View {
ZStack {
theme.currentTheme.secondaryBGColor
diff --git a/docs/AppStoreScreens.pxd b/docs/AppStoreScreens.pxd
index f1083fb..8c67886 100644
Binary files a/docs/AppStoreScreens.pxd and b/docs/AppStoreScreens.pxd differ