diff --git a/Shared/Services/ExportableWatchViews.swift b/Shared/Services/ExportableWatchViews.swift new file mode 100644 index 0000000..0382d8d --- /dev/null +++ b/Shared/Services/ExportableWatchViews.swift @@ -0,0 +1,365 @@ +// +// ExportableWatchViews.swift +// Feels +// +// Exportable watch views that match the real watchOS layouts. +// These views accept tint/icon configuration as parameters for batch export. +// + +#if DEBUG +import SwiftUI + +// MARK: - Watch Export Configuration + +/// Configuration for watch view export styling +struct WatchExportConfig { + let moodTint: MoodTintable.Type + let moodImages: MoodImagable.Type + + /// Get emoji for a mood based on the image style + func emoji(for mood: Mood) -> String { + // Map MoodImagable type to WatchMoodImageStyle equivalent + switch String(describing: moodImages) { + case "FontAwesomeMoodImages": + return fontAwesomeEmoji(for: mood) + case "EmojiMoodImages": + return emojiStyle(for: mood) + case "HandEmojiMoodImages": + return handEmoji(for: mood) + case "WeatherMoodImages": + return weatherEmoji(for: mood) + case "GardenMoodImages": + return gardenEmoji(for: mood) + case "HeartsMoodImages": + return heartsEmoji(for: mood) + case "CosmicMoodImages": + return cosmicEmoji(for: mood) + default: + return emojiStyle(for: mood) + } + } + + private func fontAwesomeEmoji(for mood: Mood) -> String { + switch mood { + case .great: return "😁" + case .good: return "🙂" + case .average: return "😐" + case .bad: return "🙁" + case .horrible: return "😫" + case .missing, .placeholder: return "❓" + } + } + + private func emojiStyle(for mood: Mood) -> String { + switch mood { + case .great: return "😀" + case .good: return "🙂" + case .average: return "😑" + case .bad: return "😕" + case .horrible: return "💩" + case .missing, .placeholder: return "❓" + } + } + + private func handEmoji(for mood: Mood) -> String { + switch mood { + case .great: return "🙏" + case .good: return "👍" + case .average: return "🖖" + case .bad: return "👎" + case .horrible: return "🖕" + case .missing, .placeholder: return "❓" + } + } + + private func weatherEmoji(for mood: Mood) -> String { + switch mood { + case .great: return "☀️" + case .good: return "⛅" + case .average: return "☁️" + case .bad: return "🌧️" + case .horrible: return "⛈️" + case .missing: return "🌫️" + case .placeholder: return "❓" + } + } + + private func gardenEmoji(for mood: Mood) -> String { + switch mood { + case .great: return "🌸" + case .good: return "🌿" + case .average: return "🌱" + case .bad: return "🍂" + case .horrible: return "🥀" + case .missing: return "🕳️" + case .placeholder: return "❓" + } + } + + private func heartsEmoji(for mood: Mood) -> String { + switch mood { + case .great: return "💖" + case .good: return "🩷" + case .average: return "🤍" + case .bad: return "🩶" + case .horrible: return "💔" + case .missing: return "🖤" + case .placeholder: return "❓" + } + } + + private func cosmicEmoji(for mood: Mood) -> String { + switch mood { + case .great: return "⭐" + case .good: return "🌕" + case .average: return "🌓" + case .bad: return "🌑" + case .horrible: return "🕳️" + case .missing: return "✧" + case .placeholder: return "❓" + } + } +} + +// MARK: - Exportable Watch Voting View + +/// Watch voting interface - matches ContentView from watch app +struct ExportableWatchVotingView: View { + let config: WatchExportConfig + + var body: some View { + VStack(spacing: 8) { + Text("How do you feel?") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.secondary) + + // Top row: Great, Good, Average + HStack(spacing: 8) { + ExportableWatchMoodButton(mood: .great, config: config) + ExportableWatchMoodButton(mood: .good, config: config) + ExportableWatchMoodButton(mood: .average, config: config) + } + + // Bottom row: Bad, Horrible + HStack(spacing: 8) { + ExportableWatchMoodButton(mood: .bad, config: config) + ExportableWatchMoodButton(mood: .horrible, config: config) + } + } + } +} + +// MARK: - Exportable Watch Mood Button + +struct ExportableWatchMoodButton: View { + let mood: Mood + let config: WatchExportConfig + + var body: some View { + Text(config.emoji(for: mood)) + .font(.system(size: 28)) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(config.moodTint.color(forMood: mood).opacity(0.3)) + .cornerRadius(12) + } +} + +// MARK: - Exportable Watch Already Rated View + +struct ExportableWatchAlreadyRatedView: View { + let mood: Mood + let config: WatchExportConfig + + var body: some View { + VStack(spacing: 12) { + Text(config.emoji(for: mood)) + .font(.system(size: 50)) + + Text("Logged!") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.secondary) + } + } +} + +// MARK: - Exportable Circular Complication + +struct ExportableCircularComplication: View { + let mood: Mood? + let config: WatchExportConfig + + var body: some View { + ZStack { + Circle() + .fill(Color(white: 0.15)) + + if let mood = mood { + Text(config.emoji(for: mood)) + .font(.system(size: 24)) + } else { + VStack(spacing: 0) { + Image(systemName: "face.smiling") + .font(.system(size: 18)) + Text("Log") + .font(.system(size: 10)) + } + } + } + } +} + +// MARK: - Exportable Corner Complication + +struct ExportableCornerComplication: View { + let mood: Mood? + let config: WatchExportConfig + + var body: some View { + HStack(spacing: 4) { + if let mood = mood { + Text(config.emoji(for: mood)) + .font(.system(size: 20)) + Text(mood.widgetDisplayName) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } else { + Image(systemName: "face.smiling") + .font(.system(size: 20)) + Text("Log mood") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + } +} + +// MARK: - Exportable Inline Complication + +struct ExportableInlineComplication: View { + let mood: Mood? + let streak: Int + let config: WatchExportConfig + + var body: some View { + HStack(spacing: 4) { + if streak > 0 { + Image(systemName: "flame.fill") + .foregroundColor(.orange) + Text("\(streak) day streak") + } else if let mood = mood { + Text("\(config.emoji(for: mood)) \(mood.widgetDisplayName)") + } else { + Image(systemName: "face.smiling") + Text("Log your mood") + } + } + .font(.system(size: 14)) + } +} + +// MARK: - Exportable Rectangular Complication + +struct ExportableRectangularComplication: View { + let mood: Mood? + let streak: Int + let config: WatchExportConfig + + var body: some View { + HStack { + if let mood = mood { + Text(config.emoji(for: mood)) + .font(.system(size: 28)) + + VStack(alignment: .leading, spacing: 2) { + Text("Today") + .font(.system(size: 12)) + .foregroundColor(.secondary) + Text(mood.widgetDisplayName) + .font(.system(size: 14, weight: .semibold)) + + if streak > 1 { + HStack(spacing: 2) { + Image(systemName: "flame.fill") + Text("\(streak) days") + } + .font(.system(size: 10)) + .foregroundColor(.orange) + } + } + } else { + Image(systemName: "face.smiling") + .font(.system(size: 24)) + + VStack(alignment: .leading, spacing: 2) { + Text("Feels") + .font(.system(size: 14, weight: .semibold)) + Text("Tap to log mood") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + + Spacer() + } + } +} + +// MARK: - Watch Container for Export + +struct ExportableWatchContainer: View { + let width: CGFloat + let height: CGFloat + let colorScheme: ColorScheme + let cornerRadius: CGFloat + let content: Content + + init(width: CGFloat, height: CGFloat, colorScheme: ColorScheme, cornerRadius: CGFloat = 20, @ViewBuilder content: () -> Content) { + self.width = width + self.height = height + self.colorScheme = colorScheme + self.cornerRadius = cornerRadius + self.content = content() + } + + private var backgroundColor: Color { + colorScheme == .dark ? Color.black : Color(red: 0.95, green: 0.95, blue: 0.97) + } + + var body: some View { + content + .environment(\.colorScheme, colorScheme) + .frame(width: width, height: height) + .background(backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } +} + +// MARK: - Watch Complication Container + +struct ExportableComplicationContainer: View { + let size: CGSize + let colorScheme: ColorScheme + let isCircular: Bool + let content: Content + + init(size: CGSize, colorScheme: ColorScheme, isCircular: Bool = false, @ViewBuilder content: () -> Content) { + self.size = size + self.colorScheme = colorScheme + self.isCircular = isCircular + self.content = content() + } + + private var backgroundColor: Color { + colorScheme == .dark ? Color(white: 0.1) : Color(white: 0.95) + } + + var body: some View { + content + .environment(\.colorScheme, colorScheme) + .frame(width: size.width, height: size.height) + .background(backgroundColor) + .clipShape(isCircular ? AnyShape(Circle()) : AnyShape(RoundedRectangle(cornerRadius: 12, style: .continuous))) + } +} +#endif diff --git a/Shared/Services/WatchExporter.swift b/Shared/Services/WatchExporter.swift new file mode 100644 index 0000000..7f3fc0c --- /dev/null +++ b/Shared/Services/WatchExporter.swift @@ -0,0 +1,250 @@ +// +// WatchExporter.swift +// Feels +// +// Debug utility to export all watch view previews to PNG files. +// Uses the exportable watch views from ExportableWatchViews.swift. +// + +#if DEBUG +import SwiftUI +import UIKit + +/// Exports watch view previews to PNG files for App Store screenshots +@MainActor +class WatchExporter { + + // MARK: - Watch Sizes (Apple Watch Series 9 45mm @ 2x) + + /// Main watch app screen size (45mm watch) + static let watchAppSize = CGSize(width: 198, height: 242) + + /// Complication sizes + enum ComplicationSize { + case circular // 50x50 pt + case corner // 40x40 pt content area + case inline // 230x26 pt + case rectangular // 180x70 pt + + var pointSize: CGSize { + switch self { + case .circular: return CGSize(width: 50, height: 50) + case .corner: return CGSize(width: 100, height: 40) + case .inline: return CGSize(width: 230, height: 26) + case .rectangular: return CGSize(width: 180, height: 70) + } + } + + var name: String { + switch self { + case .circular: return "circular" + case .corner: return "corner" + case .inline: return "inline" + case .rectangular: return "rectangular" + } + } + } + + // MARK: - Available Theme Combinations (same as WidgetExporter) + + /// All available tint options for export + static let allTints: [(name: String, tint: MoodTintable.Type)] = [ + ("Default", DefaultMoodTint.self), + ("Neon", NeonMoodTint.self), + ("Pastel", PastelTint.self), + ("Monochrome", MonoChromeTint.self) + ] + + /// All available icon options for export + static let allIcons: [(name: String, images: MoodImagable.Type)] = [ + ("Emoji", EmojiMoodImages.self), + ("FontAwesome", FontAwesomeMoodImages.self), + ("Weather", WeatherMoodImages.self), + ("Garden", GardenMoodImages.self), + ("Hearts", HeartsMoodImages.self), + ("Cosmic", CosmicMoodImages.self), + ("HandEmoji", HandEmojiMoodImages.self) + ] + + // MARK: - Export All Watch Views + + /// Exports all watch view variations to disk + /// - Returns: URL to the export directory, or nil if failed + static func exportAllWatchViews() async -> URL? { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let exportPath = documentsPath.appendingPathComponent("WatchExports", isDirectory: true) + + // Clean and create export directory + try? FileManager.default.removeItem(at: exportPath) + try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true) + + var totalExported = 0 + + // Export all tint + icon combinations + for tintOption in allTints { + for iconOption in allIcons { + let folderName = "\(tintOption.name)_\(iconOption.name)" + let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true) + try? FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true) + + let config = WatchExportConfig( + moodTint: tintOption.tint, + moodImages: iconOption.images + ) + + let count = await exportWatchViewsForConfig(config: config, to: variantPath) + totalExported += count + print(" Exported \(count) watch images to \(folderName)/") + } + } + + print("⌚ Total \(totalExported) watch views exported to: \(exportPath.path)") + return exportPath + } + + /// Exports watch views for a single tint/icon configuration + private static func exportWatchViewsForConfig(config: WatchExportConfig, to folder: URL) async -> Int { + var count = 0 + + for colorScheme in [ColorScheme.light, ColorScheme.dark] { + let schemeName = colorScheme == .light ? "light" : "dark" + + // Watch App - Voting View + await exportWatchVotingView(config: config, colorScheme: colorScheme, to: folder, name: "watch_voting_\(schemeName)") + count += 1 + + // Watch App - Already Rated (all moods) + for mood in Mood.allValues { + await exportWatchAlreadyRatedView(mood: mood, config: config, colorScheme: colorScheme, to: folder, name: "watch_logged_\(schemeName)_\(mood.strValue.lowercased())") + count += 1 + } + + // Complications - Empty state + await exportCircularComplication(mood: nil, config: config, colorScheme: colorScheme, to: folder, name: "complication_circular_\(schemeName)_empty") + await exportCornerComplication(mood: nil, config: config, colorScheme: colorScheme, to: folder, name: "complication_corner_\(schemeName)_empty") + await exportInlineComplication(mood: nil, streak: 0, config: config, colorScheme: colorScheme, to: folder, name: "complication_inline_\(schemeName)_empty") + await exportRectangularComplication(mood: nil, streak: 0, config: config, colorScheme: colorScheme, to: folder, name: "complication_rectangular_\(schemeName)_empty") + count += 4 + + // Complications - With streak + await exportInlineComplication(mood: nil, streak: 45, config: config, colorScheme: colorScheme, to: folder, name: "complication_inline_\(schemeName)_streak") + count += 1 + + // Complications - All moods + for mood in Mood.allValues { + let moodName = mood.strValue.lowercased() + await exportCircularComplication(mood: mood, config: config, colorScheme: colorScheme, to: folder, name: "complication_circular_\(schemeName)_\(moodName)") + await exportCornerComplication(mood: mood, config: config, colorScheme: colorScheme, to: folder, name: "complication_corner_\(schemeName)_\(moodName)") + await exportInlineComplication(mood: mood, streak: 0, config: config, colorScheme: colorScheme, to: folder, name: "complication_inline_\(schemeName)_\(moodName)") + await exportRectangularComplication(mood: mood, streak: 45, config: config, colorScheme: colorScheme, to: folder, name: "complication_rectangular_\(schemeName)_\(moodName)") + count += 4 + } + } + + return count + } + + // MARK: - Export Watch App Views + + private static func exportWatchVotingView(config: WatchExportConfig, colorScheme: ColorScheme, to folder: URL, name: String) async { + let content = ExportableWatchVotingView(config: config) + + let view = ExportableWatchContainer( + width: watchAppSize.width, + height: watchAppSize.height, + colorScheme: colorScheme + ) { + content + } + + await renderAndSave(view: view, size: watchAppSize, to: folder, name: name) + } + + private static func exportWatchAlreadyRatedView(mood: Mood, config: WatchExportConfig, colorScheme: ColorScheme, to folder: URL, name: String) async { + let content = ExportableWatchAlreadyRatedView(mood: mood, config: config) + + let view = ExportableWatchContainer( + width: watchAppSize.width, + height: watchAppSize.height, + colorScheme: colorScheme + ) { + content + } + + await renderAndSave(view: view, size: watchAppSize, to: folder, name: name) + } + + // MARK: - Export Complications + + private static func exportCircularComplication(mood: Mood?, config: WatchExportConfig, colorScheme: ColorScheme, to folder: URL, name: String) async { + let size = ComplicationSize.circular.pointSize + let content = ExportableCircularComplication(mood: mood, config: config) + + let view = ExportableComplicationContainer( + size: size, + colorScheme: colorScheme, + isCircular: true + ) { + content + } + + await renderAndSave(view: view, size: size, to: folder, name: name) + } + + private static func exportCornerComplication(mood: Mood?, config: WatchExportConfig, colorScheme: ColorScheme, to folder: URL, name: String) async { + let size = ComplicationSize.corner.pointSize + let content = ExportableCornerComplication(mood: mood, config: config) + + let view = ExportableComplicationContainer( + size: size, + colorScheme: colorScheme + ) { + content + } + + await renderAndSave(view: view, size: size, to: folder, name: name) + } + + private static func exportInlineComplication(mood: Mood?, streak: Int, config: WatchExportConfig, colorScheme: ColorScheme, to folder: URL, name: String) async { + let size = ComplicationSize.inline.pointSize + let content = ExportableInlineComplication(mood: mood, streak: streak, config: config) + + let view = ExportableComplicationContainer( + size: size, + colorScheme: colorScheme + ) { + content + } + + await renderAndSave(view: view, size: size, to: folder, name: name) + } + + private static func exportRectangularComplication(mood: Mood?, streak: Int, config: WatchExportConfig, colorScheme: ColorScheme, to folder: URL, name: String) async { + let size = ComplicationSize.rectangular.pointSize + let content = ExportableRectangularComplication(mood: mood, streak: streak, config: config) + + let view = ExportableComplicationContainer( + size: size, + colorScheme: colorScheme + ) { + content + } + + await renderAndSave(view: view, size: size, to: folder, name: name) + } + + // MARK: - Render and Save + + private static func renderAndSave(view: V, size: CGSize, to folder: URL, name: String) async { + let renderer = ImageRenderer(content: view.frame(width: size.width, height: size.height)) + renderer.scale = 3.0 // 3x for high res + + if let image = renderer.uiImage { + let url = folder.appendingPathComponent("\(name).png") + if let data = image.pngData() { + try? data.write(to: url) + } + } + } +} +#endif diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift index d52be1b..491d19d 100644 --- a/Shared/Views/SettingsView/SettingsView.swift +++ b/Shared/Views/SettingsView/SettingsView.swift @@ -21,6 +21,10 @@ struct SettingsContentView: View { @State private var showTrialDatePicker = false @State private var isExportingWidgets = false @State private var widgetExportPath: URL? + @State private var isExportingVotingLayouts = false + @State private var votingLayoutExportPath: URL? + @State private var isExportingWatchViews = false + @State private var watchExportPath: URL? @State private var isDeletingHealthKitData = false @State private var healthKitDeleteResult: String? @StateObject private var healthService = HealthService.shared @@ -64,6 +68,8 @@ struct SettingsContentView: View { tipsPreviewButton testNotificationsButton exportWidgetsButton + exportVotingLayoutsButton + exportWatchViewsButton deleteHealthKitDataButton clearDataButton @@ -478,6 +484,112 @@ struct SettingsContentView: View { .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } + private var exportVotingLayoutsButton: some View { + ZStack { + theme.currentTheme.secondaryBGColor + Button { + isExportingVotingLayouts = true + Task { + votingLayoutExportPath = await WidgetExporter.exportAllVotingLayouts() + isExportingVotingLayouts = false + if let path = votingLayoutExportPath { + print("📸 Voting layouts exported to: \(path.path)") + } + } + } label: { + HStack(spacing: 12) { + if isExportingVotingLayouts { + ProgressView() + .frame(width: 32) + } else { + Image(systemName: "hand.tap.fill") + .font(.title2) + .foregroundColor(.blue) + .frame(width: 32) + } + + VStack(alignment: .leading, spacing: 2) { + Text("Export Voting Layouts") + .foregroundColor(textColor) + + if let path = votingLayoutExportPath { + Text("Saved to Documents/VotingLayoutExports") + .font(.caption) + .foregroundColor(.green) + } else { + Text("All sizes & theme variations") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Image(systemName: "arrow.down.doc.fill") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding() + } + .disabled(isExportingVotingLayouts) + } + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) + } + + private var exportWatchViewsButton: some View { + ZStack { + theme.currentTheme.secondaryBGColor + Button { + isExportingWatchViews = true + Task { + watchExportPath = await WatchExporter.exportAllWatchViews() + isExportingWatchViews = false + if let path = watchExportPath { + print("⌚ Watch views exported to: \(path.path)") + } + } + } label: { + HStack(spacing: 12) { + if isExportingWatchViews { + ProgressView() + .frame(width: 32) + } else { + Image(systemName: "applewatch.watchface") + .font(.title2) + .foregroundColor(.cyan) + .frame(width: 32) + } + + VStack(alignment: .leading, spacing: 2) { + Text("Export Watch Screenshots") + .foregroundColor(textColor) + + if let path = watchExportPath { + Text("Saved to Documents/WatchExports") + .font(.caption) + .foregroundColor(.green) + } else { + Text("All styles & complications") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Image(systemName: "arrow.down.doc.fill") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding() + } + .disabled(isExportingWatchViews) + } + .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/feels-promo/public/watch_voting_light.png b/feels-promo/public/watch_voting_light.png new file mode 100644 index 0000000..2d1a0ec Binary files /dev/null and b/feels-promo/public/watch_voting_light.png differ diff --git a/feels-promo/src/ConceptB-NoJournalJournal.tsx b/feels-promo/src/ConceptB-NoJournalJournal.tsx index 7f51c08..6129fb6 100644 --- a/feels-promo/src/ConceptB-NoJournalJournal.tsx +++ b/feels-promo/src/ConceptB-NoJournalJournal.tsx @@ -139,7 +139,7 @@ const ProblemScene: React.FC = () => { // Scene 2: Single tap voting - Widget, Watch, In-App const SingleTapScene: React.FC = () => { const frame = useCurrentFrame(); - const { fps, width } = useVideoConfig(); + const { fps, width, height } = useVideoConfig(); // Title animations (matching other scenes) const line1Progress = spring({ frame, fps, config: { damping: 200 } }); @@ -174,7 +174,7 @@ const SingleTapScene: React.FC = () => { }); - const watchSize = width * 0.27; + const watchSize = width * 0.27 * 1.2; const widgetSize = width * 0.33; return ( @@ -239,172 +239,165 @@ const SingleTapScene: React.FC = () => { - {/* Three device rows - full width, image at 33%, label at 66% */} + {/* Watch row - top at 33% */}
- {/* Watch row */}
- {/* Image at 33% */}
-
+ - - -
-
- {/* Label at 66% */} -
- Watch -
-
- - {/* In-App row */} -
- {/* Image at 33% */} -
-
- {/* Label at 66% */} -
- In-App -
- - {/* Widget row */}
- {/* Image at 33% */} -
+
+ + {/* In-App row - centered at 61.5% (midpoint of 33% and 90%) */} +
+
+ - -
- {/* Label at 66% */} -
+
+
+ In-App +
+
+ + {/* Widget row - bottom at 90% (10% from bottom) */} +
+
+ - Widget -
+ /> +
+
+ Widget