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