From bfef0a4472c7a519c222cf15dbc0ffc96cbd87bd Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 10 Feb 2026 09:26:21 -0600 Subject: [PATCH] Add sharing style picker for design variation selection Users can now swipe between design variations (e.g. Gradient vs Color Block) when sharing from month/year views and the sharing templates list. Removes #if DEBUG wrappers from variation files and disables auto-start demo animation. Co-Authored-By: Claude Opus 4.6 --- Shared/Views/MonthView/MonthView.swift | 41 +-- Shared/Views/Sharing/SharingListView.swift | 136 ++++++--- .../Sharing/SharingStylePickerView.swift | 267 ++++++++++++++++++ .../Variations/AllMoodsVariations.swift | 213 ++++++++++++++ .../Variations/CurrentStreakVariations.swift | 209 ++++++++++++++ .../Variations/LongestStreakVariations.swift | 239 ++++++++++++++++ .../Variations/MonthTotalVariations.swift | 221 +++++++++++++++ Shared/Views/YearView/YearView.swift | 42 +-- 8 files changed, 1286 insertions(+), 82 deletions(-) create mode 100644 Shared/Views/Sharing/SharingStylePickerView.swift create mode 100644 Shared/Views/SharingTemplates/Variations/AllMoodsVariations.swift create mode 100644 Shared/Views/SharingTemplates/Variations/CurrentStreakVariations.swift create mode 100644 Shared/Views/SharingTemplates/Variations/LongestStreakVariations.swift create mode 100644 Shared/Views/SharingTemplates/Variations/MonthTotalVariations.swift diff --git a/Shared/Views/MonthView/MonthView.swift b/Shared/Views/MonthView/MonthView.swift index 8450d9e..5c75e18 100644 --- a/Shared/Views/MonthView/MonthView.swift +++ b/Shared/Views/MonthView/MonthView.swift @@ -20,6 +20,7 @@ struct MonthView: View { @AppStorage(UserDefaultsStore.Keys.shape.rawValue, store: GroupUserDefaults.groupDefaults) private var shape: BGShape = .circle @StateObject private var shareImage = ShareImageStateViewModel() + @State private var sharePickerData: SharePickerData? = nil @EnvironmentObject var iapManager: IAPManager @StateObject private var selectedDetail = DetailViewStateViewModel() @@ -192,9 +193,22 @@ struct MonthView: View { selectedDetail.selectedItem = detailView selectedDetail.showSheet = true }, - onShare: { image in - shareImage.selectedShareImage = image - shareImage.showSheet = true + onShare: { metrics, entries, month in + sharePickerData = SharePickerData( + title: Random.monthName(fromMonthInt: month), + designs: [ + SharingDesign( + name: "Clean Calendar", + shareView: AnyView(MonthTotalV1(moodMetrics: metrics, moodEntries: entries, month: month)), + image: { MonthTotalV1(moodMetrics: metrics, moodEntries: entries, month: month).image } + ), + SharingDesign( + name: "Stacked Bars", + shareView: AnyView(MonthTotalV5(moodMetrics: metrics, moodEntries: entries, month: month)), + image: { MonthTotalV5(moodMetrics: metrics, moodEntries: entries, month: month).image } + ), + ] + ) } ) .id("month-\(monthIndex)") @@ -337,10 +351,8 @@ struct MonthView: View { onDismiss: didDismiss) { selectedDetail.selectedItem } - .sheet(isPresented: self.$shareImage.showSheet) { - if let uiImage = self.shareImage.selectedShareImage { - ImageOnlyShareSheet(photo: uiImage) - } + .sheet(item: $sharePickerData) { data in + SharingStylePickerView(title: data.title, designs: data.designs) } .onPreferenceChange(ViewOffsetKey.self) { value in withAnimation { @@ -349,15 +361,7 @@ struct MonthView: View { } .onAppear { cachedSortedData = computeSortedYearMonthData() - #if DEBUG - // Auto-start or restart demo mode for video recording - if demoManager.isDemoMode { - // Already in demo mode (e.g., came from YearView), restart animation - demoManager.restartAnimation() - } else { - demoManager.startDemoMode() - } - #endif + // Demo mode is toggled manually via triple-tap } .onChange(of: viewModel.numberOfItems) { _, _ in // Use numberOfItems as a lightweight proxy for data changes @@ -398,7 +402,7 @@ struct MonthCard: View, Equatable { let filteredDays: [Int] let monthIndex: Int // Index for demo animation sequencing let onTap: () -> Void - let onShare: (UIImage) -> Void + let onShare: ([MoodMetrics], [MoodEntryModel], Int) -> Void private var labelColor: Color { theme.currentTheme.labelColor } @@ -587,8 +591,7 @@ struct MonthCard: View, Equatable { Spacer() Button(action: { - let image = shareableView.asImage(size: CGSize(width: 400, height: 700)) - onShare(image) + onShare(cachedMetrics, entries, month) }) { Image(systemName: "square.and.arrow.up") .font(.subheadline.weight(.medium)) diff --git a/Shared/Views/Sharing/SharingListView.swift b/Shared/Views/Sharing/SharingListView.swift index a3ad218..7a10744 100644 --- a/Shared/Views/Sharing/SharingListView.swift +++ b/Shared/Views/Sharing/SharingListView.swift @@ -37,54 +37,102 @@ struct SharingListView: View { @MainActor init() { + let earliestDate = DataController.shared.earliestEntry?.forDate ?? Date() + let now = Date() + let tenDaysAgo = Calendar.current.date(byAdding: .day, value: -10, to: now)! + let monthStart = now.startOfMonth + let monthEnd = now.endOfMonth + + // — All Moods — + let allMoodsEntries = DataController.shared.getData(startDate: earliestDate, endDate: now, includedDays: [1,2,3,4,5,6,7]) + let allMoodsMetrics = Random.createTotalPerc(fromEntries: allMoodsEntries) + let allMoodsCount = allMoodsEntries.count + + // — Current Streak (Last 10 Days) — + let streakEntries = DataController.shared.getData(startDate: tenDaysAgo, endDate: now, includedDays: [1,2,3,4,5,6,7]) + + // — Month Total — + let monthEntries = DataController.shared.getData(startDate: monthStart, endDate: monthEnd, includedDays: [1,2,3,4,5,6,7]) + let monthMetrics = Random.createTotalPerc(fromEntries: monthEntries) + let month = Calendar.current.component(.month, from: now) + self.sharebleItems = [ - WrappedSharable(preview: AnyView( - AllMoodsTotalTemplate(isPreview: true, - startDate: DataController.shared.earliestEntry?.forDate ?? Date(), - endDate: Date(), - fakeData: false) - ),destination: AnyView( - AllMoodsTotalTemplate(isPreview: false, - startDate: DataController.shared.earliestEntry?.forDate ?? Date(), - endDate: Date(), - fakeData: false) - ),description: AllMoodsTotalTemplate.description), + // All Time Moods — style picker with 2 variations + WrappedSharable( + preview: AnyView( + AllMoodsTotalTemplate(isPreview: true, startDate: earliestDate, endDate: now, fakeData: false) + ), + destination: AnyView( + SharingStylePickerView(title: "All Time Moods", designs: [ + SharingDesign( + name: "Gradient", + shareView: AnyView(AllMoodsV2(metrics: allMoodsMetrics, totalCount: allMoodsCount)), + image: { AllMoodsV2(metrics: allMoodsMetrics, totalCount: allMoodsCount).image } + ), + SharingDesign( + name: "Color Block", + shareView: AnyView(AllMoodsV5(metrics: allMoodsMetrics, totalCount: allMoodsCount)), + image: { AllMoodsV5(metrics: allMoodsMetrics, totalCount: allMoodsCount).image } + ), + ]) + ), + description: AllMoodsTotalTemplate.description + ), ////////////////////////////////////////////////////////// - WrappedSharable(preview: AnyView( - CurrentStreakTemplate(isPreview: true, - startDate: Calendar.current.date(byAdding: .day, value: -10, to: Date())!, - endDate: Date(), - fakeData: false) - ), destination: AnyView( - CurrentStreakTemplate(isPreview: false, - startDate: Calendar.current.date(byAdding: .day, value: -10, to: Date())!, - endDate: Date(), - fakeData: false) - ), description: CurrentStreakTemplate.description), + // Last 10 Days — style picker with 2 variations + WrappedSharable( + preview: AnyView( + CurrentStreakTemplate(isPreview: true, startDate: tenDaysAgo, endDate: now, fakeData: false) + ), + destination: AnyView( + SharingStylePickerView(title: "Last 10 Days", designs: [ + SharingDesign( + name: "Gradient Cards", + shareView: AnyView(CurrentStreakV2(moodEntries: streakEntries)), + image: { CurrentStreakV2(moodEntries: streakEntries).image } + ), + SharingDesign( + name: "Color Strips", + shareView: AnyView(CurrentStreakV5(moodEntries: streakEntries)), + image: { CurrentStreakV5(moodEntries: streakEntries).image } + ), + ]) + ), + description: CurrentStreakTemplate.description + ), ////////////////////////////////////////////////////////// - WrappedSharable(preview: AnyView( - LongestStreakTemplate(isPreview: true, - startDate: DataController.shared.earliestEntry?.forDate ?? Date(), - endDate: Date(), - fakeData: false) - ), destination: AnyView( - LongestStreakTemplate(isPreview: false, - startDate: DataController.shared.earliestEntry?.forDate ?? Date(), - endDate: Date(), - fakeData: false) - ), description: LongestStreakTemplate.description), + // Longest Streak — custom picker with mood selection + WrappedSharable( + preview: AnyView( + LongestStreakTemplate(isPreview: true, startDate: earliestDate, endDate: now, fakeData: false) + ), + destination: AnyView( + LongestStreakPickerView(startDate: earliestDate, endDate: now) + ), + description: LongestStreakTemplate.description + ), ////////////////////////////////////////////////////////// - WrappedSharable(preview: AnyView( - MonthTotalTemplate(isPreview: true, - startDate: Date().startOfMonth, - endDate: Date().endOfMonth, - fakeData: false) - ), destination: AnyView( - MonthTotalTemplate(isPreview: false, - startDate: Date().startOfMonth, - endDate: Date().endOfMonth, - fakeData: false) - ), description: MonthTotalTemplate.description) + // This Month — style picker with 2 variations + WrappedSharable( + preview: AnyView( + MonthTotalTemplate(isPreview: true, startDate: monthStart, endDate: monthEnd, fakeData: false) + ), + destination: AnyView( + SharingStylePickerView(title: "This Month", designs: [ + SharingDesign( + name: "Clean Calendar", + shareView: AnyView(MonthTotalV1(moodMetrics: monthMetrics, moodEntries: monthEntries, month: month)), + image: { MonthTotalV1(moodMetrics: monthMetrics, moodEntries: monthEntries, month: month).image } + ), + SharingDesign( + name: "Stacked Bars", + shareView: AnyView(MonthTotalV5(moodMetrics: monthMetrics, moodEntries: monthEntries, month: month)), + image: { MonthTotalV5(moodMetrics: monthMetrics, moodEntries: monthEntries, month: month).image } + ), + ]) + ), + description: MonthTotalTemplate.description + ), ////////////////////////////////////////////////////////// ] } diff --git a/Shared/Views/Sharing/SharingStylePickerView.swift b/Shared/Views/Sharing/SharingStylePickerView.swift new file mode 100644 index 0000000..522e413 --- /dev/null +++ b/Shared/Views/Sharing/SharingStylePickerView.swift @@ -0,0 +1,267 @@ +// +// SharingStylePickerView.swift +// Feels +// +// A horizontal pager that lets users swipe between design variations +// for a sharing template, then export the selected design. +// + +import SwiftUI + +struct SharingDesign: Identifiable { + let id = UUID() + let name: String + let shareView: AnyView + let image: () -> UIImage +} + +struct SharePickerData: Identifiable { + let id = UUID() + let title: String + let designs: [SharingDesign] +} + +struct SharingStylePickerView: View { + let title: String + let designs: [SharingDesign] + + @State private var selectedIndex = 0 + @StateObject private var shareImage = ShareImageStateViewModel() + @Environment(\.presentationMode) var presentationMode + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system + + private var textColor: Color { theme.currentTheme.labelColor } + + private var safeIndex: Int { + guard !designs.isEmpty else { return 0 } + return min(selectedIndex, designs.count - 1) + } + + var body: some View { + if designs.isEmpty { + Text("No designs available") + .foregroundColor(textColor) + } else { + VStack(spacing: 0) { + // Title bar + HStack { + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + Text("Exit") + .font(.headline) + .foregroundColor(.red) + } + + Spacer() + + Text(title) + .font(.headline) + .foregroundColor(textColor) + + Spacer() + + // Invisible placeholder for symmetry + Text("Exit") + .font(.headline) + .hidden() + } + .padding(.horizontal) + .padding(.vertical, 12) + + // Pager showing scaled-down shareViews + TabView(selection: $selectedIndex) { + ForEach(Array(designs.enumerated()), id: \.offset) { index, design in + design.shareView + .scaleEffect(0.45, anchor: .center) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + .tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: .always)) + + // Design name label + Text(designs[safeIndex].name) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(textColor) + .padding(.top, 8) + .animation(.none, value: selectedIndex) + + // Share button + Button(action: { + let img = designs[safeIndex].image() + shareImage.selectedShareImage = img + shareImage.showSheet = true + }) { + Text("Share") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.green) + .cornerRadius(14) + } + .padding(.horizontal, 24) + .padding(.top, 12) + .padding(.bottom, 24) + } + .background(theme.currentTheme.bg.edgesIgnoringSafeArea(.all)) + .sheet(isPresented: $shareImage.showSheet) { + if let uiImage = shareImage.selectedShareImage { + ShareSheet(photo: uiImage) + } + } + } + } +} + +// MARK: - Longest Streak Picker (mood selection + style picker) + +struct LongestStreakPickerView: View { + let startDate: Date + let endDate: Date + + @State private var selectedMood: Mood = .great + @State private var streakEntries: [MoodEntryModel] = [] + @State private var selectedIndex = 0 + @StateObject private var shareImage = ShareImageStateViewModel() + + @Environment(\.presentationMode) var presentationMode + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system + + private var textColor: Color { theme.currentTheme.labelColor } + + private var designs: [SharingDesign] { + [ + SharingDesign( + name: "Gradient Bar", + shareView: AnyView(LongestStreakV2(streakEntries: streakEntries, selectedMood: selectedMood)), + image: { LongestStreakV2(streakEntries: streakEntries, selectedMood: selectedMood).image } + ), + SharingDesign( + name: "Dark Badge", + shareView: AnyView(LongestStreakV3(streakEntries: streakEntries, selectedMood: selectedMood)), + image: { LongestStreakV3(streakEntries: streakEntries, selectedMood: selectedMood).image } + ), + ] + } + + var body: some View { + VStack(spacing: 0) { + // Title bar with mood picker + HStack { + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + Text("Exit") + .font(.headline) + .foregroundColor(.red) + } + + Spacer() + + Menu { + ForEach(Mood.allValues) { mood in + Button(mood.strValue) { + selectedMood = mood + recomputeStreak() + } + } + } label: { + HStack(spacing: 6) { + Text(selectedMood.strValue) + .font(.headline) + .foregroundColor(textColor) + Image(systemName: "chevron.down") + .font(.caption) + .foregroundColor(textColor.opacity(0.6)) + } + } + + Spacer() + + Text("Exit") + .font(.headline) + .hidden() + } + .padding(.horizontal) + .padding(.vertical, 12) + + // Pager + TabView(selection: $selectedIndex) { + ForEach(Array(designs.enumerated()), id: \.offset) { index, design in + design.shareView + .scaleEffect(0.45, anchor: .center) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + .tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: .always)) + + // Design name + Text(designs[selectedIndex].name) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(textColor) + .padding(.top, 8) + .animation(.none, value: selectedIndex) + + // Share button + Button(action: { + let img = designs[selectedIndex].image() + shareImage.selectedShareImage = img + shareImage.showSheet = true + }) { + Text("Share") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.green) + .cornerRadius(14) + } + .padding(.horizontal, 24) + .padding(.top, 12) + .padding(.bottom, 24) + } + .background(theme.currentTheme.bg.edgesIgnoringSafeArea(.all)) + .sheet(isPresented: $shareImage.showSheet) { + if let uiImage = shareImage.selectedShareImage { + ShareSheet(photo: uiImage) + } + } + .onAppear { recomputeStreak() } + } + + @MainActor + private func recomputeStreak() { + let allEntries = DataController.shared.getData( + startDate: startDate, + endDate: endDate, + includedDays: [1, 2, 3, 4, 5, 6, 7] + ) + var splitArrays = createSubArrays(fromMoodEntries: allEntries, splitOn: selectedMood) + splitArrays.sort { $0.count > $1.count } + streakEntries = splitArrays.first ?? [] + } + + private func createSubArrays(fromMoodEntries: [MoodEntryModel], splitOn: Mood) -> [[MoodEntryModel]] { + var splitArrays = [[MoodEntryModel]]() + var currentSplit = [MoodEntryModel]() + for entry in fromMoodEntries { + if entry.mood == splitOn { + currentSplit.append(entry) + } else { + splitArrays.append(currentSplit) + currentSplit.removeAll() + } + } + splitArrays.append(currentSplit) + return splitArrays + } +} diff --git a/Shared/Views/SharingTemplates/Variations/AllMoodsVariations.swift b/Shared/Views/SharingTemplates/Variations/AllMoodsVariations.swift new file mode 100644 index 0000000..11cd6fc --- /dev/null +++ b/Shared/Views/SharingTemplates/Variations/AllMoodsVariations.swift @@ -0,0 +1,213 @@ +// +// AllMoodsVariations.swift +// Feels +// +// 3 design variations for the All Moods Total sharing template. +// + +import SwiftUI + +// MARK: - V2 "Gradient" — Warm gradient, rounded cards, bold + +struct AllMoodsV2: View { + let metrics: [MoodMetrics] + let totalCount: Int + + @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) + private var moodTint: MoodTints = .Default + + var image: UIImage { + shareView.asImage(size: CGSize(width: 666, height: 1190)) + } + + var shareView: some View { + ZStack { + // Gradient bg + LinearGradient( + colors: [Color(hex: "FFF5EE"), Color(hex: "FFE4D6"), Color(hex: "FFD4C2")], + startPoint: .top, + endPoint: .bottom + ) + + VStack(spacing: 0) { + Spacer().frame(height: 80) + + // Title card + VStack(spacing: 12) { + Text("All Time Moods") + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundColor(Color(hex: "8B5E3C")) + + Text("\(totalCount)") + .font(.system(size: 96, weight: .heavy, design: .rounded)) + .foregroundColor(Color(hex: "4A2810")) + + Text("entries tracked") + .font(.system(size: 16, weight: .medium, design: .rounded)) + .foregroundColor(Color(hex: "8B5E3C").opacity(0.7)) + } + + Spacer().frame(height: 50) + + // Mood circles row + HStack(spacing: 20) { + ForEach(metrics.sorted(by: { $0.mood.rawValue > $1.mood.rawValue }), id: \.mood) { metric in + VStack(spacing: 12) { + ZStack { + Circle() + .fill(moodTint.color(forMood: metric.mood)) + .frame(width: 90, height: 90) + .shadow(color: moodTint.color(forMood: metric.mood).opacity(0.4), radius: 12, y: 6) + + metric.mood.icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 36, height: 36) + .foregroundColor(.white) + } + + Text("\(metric.percent, specifier: "%.0f")%") + .font(.system(size: 20, weight: .bold, design: .rounded)) + .foregroundColor(Color(hex: "4A2810")) + + Text("\(metric.total)") + .font(.system(size: 14, weight: .medium, design: .rounded)) + .foregroundColor(Color(hex: "8B5E3C").opacity(0.6)) + } + } + } + .padding(.horizontal, 24) + + Spacer() + + // Bottom card + HStack(alignment: .bottom) { + ForEach(metrics.sorted(by: { $0.percent > $1.percent }), id: \.mood) { metric in + VStack(spacing: 4) { + RoundedRectangle(cornerRadius: 8) + .fill(moodTint.color(forMood: metric.mood)) + .frame(height: CGFloat(metric.percent) * 2.5) + + Text(metric.mood.strValue) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundColor(Color(hex: "8B5E3C")) + } + .frame(maxWidth: .infinity) + } + } + .frame(height: 300, alignment: .bottom) + .padding(24) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(.white.opacity(0.6)) + ) + .padding(.horizontal, 32) + + Spacer().frame(height: 50) + + VStack(spacing: 10) { + Image("FeelsAppIcon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 96, height: 96) + .clipShape(RoundedRectangle(cornerRadius: 22)) + + Text("Feels") + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .foregroundColor(Color(hex: "8B5E3C").opacity(0.4)) + } + + Spacer().frame(height: 50) + } + } + .frame(width: 666, height: 1190) + } + + var body: some View { shareView } +} + +// MARK: - V5 "Color Block" — Mood colors fill proportional strips, white text + +struct AllMoodsV5: View { + let metrics: [MoodMetrics] + let totalCount: Int + + @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) + private var moodTint: MoodTints = .Default + + var image: UIImage { + shareView.asImage(size: CGSize(width: 666, height: 1190)) + } + + var shareView: some View { + let sorted = metrics.sorted(by: { $0.mood.rawValue > $1.mood.rawValue }) + let headerHeight: CGFloat = 80 + let footerHeight: CGFloat = 180 + let blocksHeight: CGFloat = 1190 - headerHeight - footerHeight + + return VStack(spacing: 0) { + // Header — dark band + ZStack { + Color(hex: "1C1C1E") + + Text("\(totalCount) moods") + .font(.system(size: 20, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .tracking(2) + .textCase(.uppercase) + } + .frame(height: headerHeight) + + // Color blocks — fill proportionally, no gaps + ForEach(sorted, id: \.mood) { metric in + let blockHeight = max(blocksHeight * CGFloat(metric.percent / 100), 60) + + ZStack { + moodTint.color(forMood: metric.mood) + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(metric.mood.strValue.uppercased()) + .font(.system(size: 14, weight: .bold, design: .rounded)) + .tracking(2) + + Text("\(metric.total) days") + .font(.system(size: 12, weight: .medium)) + .opacity(0.7) + } + + Spacer() + + Text("\(metric.percent, specifier: "%.0f")%") + .font(.system(size: 42, weight: .heavy, design: .rounded)) + } + .foregroundColor(.white) + .padding(.horizontal, 40) + } + .frame(height: blockHeight) + } + + // Footer — dark band + ZStack { + Color(hex: "1C1C1E") + + VStack(spacing: 10) { + Image("FeelsAppIcon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 96, height: 96) + .clipShape(RoundedRectangle(cornerRadius: 22)) + + Text("Feels") + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundColor(.white.opacity(0.3)) + } + } + .frame(maxHeight: .infinity) + } + .background(Color(hex: "1C1C1E")) + .frame(width: 666, height: 1190) + } + + var body: some View { shareView } +} diff --git a/Shared/Views/SharingTemplates/Variations/CurrentStreakVariations.swift b/Shared/Views/SharingTemplates/Variations/CurrentStreakVariations.swift new file mode 100644 index 0000000..3f6007f --- /dev/null +++ b/Shared/Views/SharingTemplates/Variations/CurrentStreakVariations.swift @@ -0,0 +1,209 @@ +// +// CurrentStreakVariations.swift +// Feels +// +// 2 design variations for the Current Streak (Last 10 Days) sharing template. +// + +import SwiftUI + +// MARK: - V2 "Gradient Cards" — 2x5 grid of colored cards + +struct CurrentStreakV2: View { + let moodEntries: [MoodEntryModel] + + @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) + private var moodTint: MoodTints = .Default + + private let dayFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEE" + return f + }() + + private let numFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "d" + return f + }() + + var image: UIImage { + shareView.asImage(size: CGSize(width: 666, height: 1190)) + } + + let columns = [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16) + ] + + var shareView: some View { + ZStack { + LinearGradient( + colors: [Color(hex: "FFF5EE"), Color(hex: "FFE8D6")], + startPoint: .top, + endPoint: .bottom + ) + + VStack(spacing: 0) { + Spacer().frame(height: 40) + + Text("Last 10 Days") + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundColor(Color(hex: "4A2810")) + + Spacer().frame(height: 6) + + Text("Your recent moods") + .font(.system(size: 16, weight: .medium, design: .rounded)) + .foregroundColor(Color(hex: "8B5E3C").opacity(0.7)) + + Spacer().frame(height: 24) + + LazyVGrid(columns: columns, spacing: 12) { + ForEach(moodEntries) { entry in + VStack(spacing: 8) { + entry.mood.icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 36, height: 36) + .foregroundColor(.white) + + Text(dayFormatter.string(from: entry.forDate ?? Date())) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundColor(.white.opacity(0.9)) + + Text(numFormatter.string(from: entry.forDate ?? Date())) + .font(.system(size: 28, weight: .heavy, design: .rounded)) + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(moodTint.color(forMood: entry.mood)) + .shadow(color: moodTint.color(forMood: entry.mood).opacity(0.3), radius: 10, y: 5) + ) + } + } + .padding(.horizontal, 36) + + Spacer() + + VStack(spacing: 10) { + Image("FeelsAppIcon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 96, height: 96) + .clipShape(RoundedRectangle(cornerRadius: 22)) + + Text("Feels") + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .foregroundColor(Color(hex: "8B5E3C").opacity(0.4)) + } + + Spacer().frame(height: 50) + } + } + .frame(width: 666, height: 1190) + } + + var body: some View { shareView } +} + +// MARK: - V5 "Color Strips" — Full-width mood-colored horizontal strips + +struct CurrentStreakV5: View { + let moodEntries: [MoodEntryModel] + + @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) + private var moodTint: MoodTints = .Default + + private let dayFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEEE" + return f + }() + + private let numFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "d" + return f + }() + + var image: UIImage { + shareView.asImage(size: CGSize(width: 666, height: 1190)) + } + + var shareView: some View { + ZStack { + Color(hex: "1C1C1E") + + VStack(spacing: 0) { + // Header + VStack(spacing: 4) { + Text("10 DAYS") + .font(.system(size: 16, weight: .heavy, design: .rounded)) + .foregroundColor(.white) + .tracking(4) + + Text("of feeling") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundColor(.white.opacity(0.5)) + } + .frame(height: 80) + + // Color strips + ForEach(moodEntries) { entry in + ZStack { + moodTint.color(forMood: entry.mood) + + HStack { + entry.mood.icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 28, height: 28) + .foregroundColor(.white.opacity(0.9)) + + Text(entry.mood.strValue) + .font(.system(size: 18, weight: .bold, design: .rounded)) + .foregroundColor(.white) + + Spacer() + + Text(dayFormatter.string(from: entry.forDate ?? Date())) + .font(.system(size: 14, weight: .medium, design: .rounded)) + .foregroundColor(.white.opacity(0.7)) + + Text(numFormatter.string(from: entry.forDate ?? Date())) + .font(.system(size: 28, weight: .heavy, design: .rounded)) + .foregroundColor(.white) + } + .padding(.horizontal, 32) + } + .frame(maxWidth: .infinity) + .frame(height: 84) + } + + Spacer() + + // Footer + VStack(spacing: 10) { + Image("FeelsAppIcon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 96, height: 96) + .clipShape(RoundedRectangle(cornerRadius: 22)) + + Text("Feels") + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundColor(.white.opacity(0.3)) + } + + Spacer().frame(height: 40) + } + } + .frame(width: 666, height: 1190) + } + + var body: some View { shareView } +} diff --git a/Shared/Views/SharingTemplates/Variations/LongestStreakVariations.swift b/Shared/Views/SharingTemplates/Variations/LongestStreakVariations.swift new file mode 100644 index 0000000..f868d6c --- /dev/null +++ b/Shared/Views/SharingTemplates/Variations/LongestStreakVariations.swift @@ -0,0 +1,239 @@ +// +// LongestStreakVariations.swift +// Feels +// +// 2 design variations for the Longest Streak sharing template. +// + +import SwiftUI + +// MARK: - V2 "Gradient Bar" — Warm gradient bg, horizontal progress + +struct LongestStreakV2: View { + let streakEntries: [MoodEntryModel] + let selectedMood: Mood + + @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) + private var moodTint: MoodTints = .Default + + private let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMM d, yyyy" + return f + }() + + var image: UIImage { + shareView.asImage(size: CGSize(width: 650, height: 400)) + } + + var shareView: some View { + ZStack { + LinearGradient( + colors: [Color(hex: "FFF5EE"), Color(hex: "FFE4D6")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + VStack(spacing: 20) { + // Top row + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Longest Streak") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundColor(Color(hex: "8B5E3C")) + .textCase(.uppercase) + .tracking(2) + + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("\(streakEntries.count)") + .font(.system(size: 56, weight: .heavy, design: .rounded)) + .foregroundColor(Color(hex: "4A2810")) + + Text("days") + .font(.system(size: 20, weight: .medium, design: .rounded)) + .foregroundColor(Color(hex: "8B5E3C")) + } + } + + Spacer() + + ZStack { + Circle() + .fill(moodTint.color(forMood: selectedMood)) + .frame(width: 72, height: 72) + .shadow(color: moodTint.color(forMood: selectedMood).opacity(0.4), radius: 12) + + selectedMood.icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 32, height: 32) + .foregroundColor(.white) + } + } + .padding(.horizontal, 36) + + // Progress bar + VStack(spacing: 8) { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(Color(hex: "4A2810").opacity(0.1)) + .frame(height: 24) + + Capsule() + .fill(moodTint.color(forMood: selectedMood)) + .frame(width: geo.size.width * 0.85, height: 24) + .shadow(color: moodTint.color(forMood: selectedMood).opacity(0.3), radius: 6) + } + } + .frame(height: 24) + + HStack { + Text(dateFormatter.string(from: streakEntries.first?.forDate ?? Date())) + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundColor(Color(hex: "8B5E3C").opacity(0.6)) + + Spacer() + + Text(dateFormatter.string(from: streakEntries.last?.forDate ?? Date())) + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundColor(Color(hex: "8B5E3C").opacity(0.6)) + } + } + .padding(.horizontal, 36) + + // Footer + HStack { + Text(selectedMood.strValue) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundColor(Color(hex: "8B5E3C")) + + Spacer() + + VStack(spacing: 6) { + Image("FeelsAppIcon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 72, height: 72) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + Text("Feels") + .font(.system(size: 22, weight: .medium, design: .rounded)) + .foregroundColor(Color(hex: "8B5E3C").opacity(0.4)) + } + } + .padding(.horizontal, 36) + } + .padding(.vertical, 32) + } + .frame(width: 650, height: 400) + } + + var body: some View { shareView } +} + +// MARK: - V3 "Dark Badge" — Dark bg, glowing badge, neon accent + +struct LongestStreakV3: View { + let streakEntries: [MoodEntryModel] + let selectedMood: Mood + + @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) + private var moodTint: MoodTints = .Default + + private let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + return f + }() + + var image: UIImage { + shareView.asImage(size: CGSize(width: 650, height: 400)) + } + + var shareView: some View { + ZStack { + Color(hex: "0A0A0F") + + HStack(spacing: 0) { + // Left half — badge centered + ZStack { + Circle() + .fill(moodTint.color(forMood: selectedMood).opacity(0.15)) + .frame(width: 220, height: 220) + + Circle() + .stroke(moodTint.color(forMood: selectedMood).opacity(0.3), lineWidth: 2) + .frame(width: 180, height: 180) + + Circle() + .stroke(moodTint.color(forMood: selectedMood), lineWidth: 3) + .frame(width: 150, height: 150) + + VStack(spacing: 4) { + Text("\(streakEntries.count)") + .font(.system(size: 60, weight: .heavy, design: .rounded)) + .foregroundColor(.white) + + Text("DAYS") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(moodTint.color(forMood: selectedMood)) + .tracking(4) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + // Right half — text left-aligned, vertically centered + VStack(alignment: .leading, spacing: 20) { + Text("LONGEST STREAK") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(Color(hex: "6E6E80")) + .tracking(4) + + HStack(spacing: 10) { + selectedMood.icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 28, height: 28) + .foregroundColor(moodTint.color(forMood: selectedMood)) + + Text(selectedMood.strValue) + .font(.system(size: 30, weight: .semibold)) + .foregroundColor(.white) + } + + VStack(alignment: .leading, spacing: 6) { + Text(dateFormatter.string(from: streakEntries.first?.forDate ?? Date())) + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + + Text("→") + .font(.system(size: 16)) + .foregroundColor(Color(hex: "6E6E80")) + + Text(dateFormatter.string(from: streakEntries.last?.forDate ?? Date())) + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + } + + VStack(spacing: 6) { + Image("FeelsAppIcon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 72, height: 72) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + Text("Feels") + .font(.system(size: 22, weight: .medium)) + .foregroundColor(Color(hex: "3A3A4A")) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.leading, 20) + } + } + .frame(width: 650, height: 400) + } + + var body: some View { shareView } +} diff --git a/Shared/Views/SharingTemplates/Variations/MonthTotalVariations.swift b/Shared/Views/SharingTemplates/Variations/MonthTotalVariations.swift new file mode 100644 index 0000000..3deb837 --- /dev/null +++ b/Shared/Views/SharingTemplates/Variations/MonthTotalVariations.swift @@ -0,0 +1,221 @@ +// +// MonthTotalVariations.swift +// Feels +// +// 3 design variations for the Month Total sharing template. +// + +import SwiftUI + +// MARK: - V1 "Clean Calendar" — Calendar-style grid, Health-app feel + +struct MonthTotalV1: View { + let moodMetrics: [MoodMetrics] + let moodEntries: [MoodEntryModel] + let month: Int + + @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) + private var moodTint: MoodTints = .Default + + let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 7) + + var image: UIImage { + shareView.asImage(size: CGSize(width: 666, height: 1190)) + } + + var shareView: some View { + ZStack { + Color.white + + VStack(spacing: 0) { + Spacer().frame(height: 70) + + // Header + VStack(spacing: 8) { + Text(Random.monthName(fromMonthInt: month)) + .font(.system(size: 36, weight: .bold, design: .rounded)) + .foregroundColor(Color(hex: "1C1C1E")) + + Text("\(moodEntries.count) DAYS TRACKED") + .font(.system(size: 14, weight: .medium, design: .rounded)) + .foregroundColor(Color(hex: "8E8E93")) + .tracking(2) + } + + Spacer().frame(height: 40) + + // Calendar grid + LazyVGrid(columns: columns, spacing: 8) { + ForEach(moodEntries) { entry in + RoundedRectangle(cornerRadius: 10) + .fill(moodTint.color(forMood: entry.mood)) + .frame(height: 70) + .overlay( + VStack(spacing: 4) { + entry.mood.icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .foregroundColor(.white) + + Text(DateFormattingCache.shared.string(for: entry.forDate ?? Date(), format: .day)) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundColor(.white.opacity(0.8)) + } + ) + } + } + .padding(.horizontal, 32) + + Spacer().frame(height: 40) + + // Stats row + HStack(spacing: 0) { + ForEach(moodMetrics.sorted(by: { $0.mood.rawValue > $1.mood.rawValue }), id: \.mood) { metric in + VStack(spacing: 8) { + Capsule() + .fill(moodTint.color(forMood: metric.mood)) + .frame(width: 40, height: 6) + + Text("\(metric.percent, specifier: "%.0f")%") + .font(.system(size: 18, weight: .bold, design: .rounded)) + .foregroundColor(Color(hex: "1C1C1E")) + + Text(metric.mood.strValue) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: "8E8E93")) + } + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 24) + + Spacer() + + VStack(spacing: 10) { + Image("FeelsAppIcon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 96, height: 96) + .clipShape(RoundedRectangle(cornerRadius: 22)) + + Text("Feels") + .font(.system(size: 28, weight: .medium, design: .rounded)) + .foregroundColor(Color(hex: "C7C7CC")) + } + + Spacer().frame(height: 50) + } + } + .frame(width: 666, height: 1190) + } + + var body: some View { shareView } +} + +// MARK: - V5 "Stacked Bars" — Full-width mood color bars, white overlays + +struct MonthTotalV5: View { + let moodMetrics: [MoodMetrics] + let moodEntries: [MoodEntryModel] + let month: Int + + @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) + private var moodTint: MoodTints = .Default + + let columns = Array(repeating: GridItem(.flexible(), spacing: 3), count: 7) + + var image: UIImage { + shareView.asImage(size: CGSize(width: 666, height: 1190)) + } + + var shareView: some View { + let sorted = moodMetrics.sorted(by: { $0.mood.rawValue > $1.mood.rawValue }) + + return VStack(spacing: 0) { + // Header — dark band + ZStack { + Color(hex: "1C1C1E") + + HStack { + Text(Random.monthName(fromMonthInt: month).uppercased()) + .font(.system(size: 18, weight: .heavy, design: .rounded)) + .foregroundColor(.white) + .tracking(4) + + Spacer() + + Text("\(moodEntries.count)") + .font(.system(size: 28, weight: .heavy, design: .rounded)) + .foregroundColor(.white) + } + .padding(.horizontal, 32) + } + .frame(height: 80) + + // Mini grid of all days + ZStack { + Color(hex: "1C1C1E") + + LazyVGrid(columns: columns, spacing: 3) { + ForEach(moodEntries) { entry in + Rectangle() + .fill(moodTint.color(forMood: entry.mood)) + .frame(height: 50) + } + } + .padding(24) + } + + // Color percentage blocks + ForEach(sorted, id: \.mood) { metric in + let barHeight: CGFloat = max(CGFloat(metric.percent / 100) * 400, 50) + + ZStack { + moodTint.color(forMood: metric.mood) + + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(metric.mood.strValue.uppercased()) + .font(.system(size: 13, weight: .bold, design: .rounded)) + .tracking(2) + Text("\(metric.total) days") + .font(.system(size: 11, weight: .medium)) + .opacity(0.7) + } + + Spacer() + + Text("\(metric.percent, specifier: "%.0f")%") + .font(.system(size: 32, weight: .heavy, design: .rounded)) + } + .foregroundColor(.white) + .padding(.horizontal, 32) + } + .frame(height: barHeight) + } + + // Footer — dark band fills remaining space + ZStack { + Color(hex: "1C1C1E") + + VStack(spacing: 10) { + Image("FeelsAppIcon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 96, height: 96) + .clipShape(RoundedRectangle(cornerRadius: 22)) + + Text("Feels") + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundColor(.white.opacity(0.3)) + } + } + .frame(maxHeight: .infinity) + } + .background(Color(hex: "1C1C1E")) + .frame(width: 666, height: 1190) + } + + var body: some View { shareView } +} diff --git a/Shared/Views/YearView/YearView.swift b/Shared/Views/YearView/YearView.swift index a4a9b42..6522cb9 100644 --- a/Shared/Views/YearView/YearView.swift +++ b/Shared/Views/YearView/YearView.swift @@ -22,6 +22,7 @@ struct YearView: View { @StateObject private var shareImage = ShareImageStateViewModel() @State private var trialWarningHidden = false @State private var showSubscriptionStore = false + @State private var sharePickerData: SharePickerData? = nil /// Cached sorted year keys to avoid re-sorting in ForEach on every render @State private var cachedSortedYearKeys: [Int] = [] @@ -146,9 +147,23 @@ struct YearView: View { filteredDays: filteredDays.currentFilters, yearIndex: yearIndex, demoManager: demoManager, - onShare: { image in - shareImage.selectedShareImage = image - shareImage.showSheet = true + onShare: { metrics, entries, year in + let totalCount = entries.filter { ![.missing, .placeholder].contains($0.mood) }.count + sharePickerData = SharePickerData( + title: String(year), + designs: [ + SharingDesign( + name: "Gradient", + shareView: AnyView(AllMoodsV2(metrics: metrics, totalCount: totalCount)), + image: { AllMoodsV2(metrics: metrics, totalCount: totalCount).image } + ), + SharingDesign( + name: "Color Block", + shareView: AnyView(AllMoodsV5(metrics: metrics, totalCount: totalCount)), + image: { AllMoodsV5(metrics: metrics, totalCount: totalCount).image } + ), + ] + ) } ) } @@ -260,23 +275,13 @@ struct YearView: View { .sheet(isPresented: $showSubscriptionStore) { FeelsSubscriptionStoreView() } - .sheet(isPresented: $shareImage.showSheet) { - if let uiImage = shareImage.selectedShareImage { - ImageOnlyShareSheet(photo: uiImage) - } + .sheet(item: $sharePickerData) { data in + SharingStylePickerView(title: data.title, designs: data.designs) } .onAppear(perform: { self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date()) cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >)) - #if DEBUG - // Auto-start or restart demo mode for video recording - if demoManager.isDemoMode { - // Already in demo mode (e.g., came from MonthView), restart animation - demoManager.restartAnimation() - } else { - demoManager.startDemoMode() - } - #endif + // Demo mode is toggled manually via triple-tap }) .onChange(of: viewModel.data.keys.count) { _, _ in cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >)) @@ -321,7 +326,7 @@ struct YearCard: View, Equatable { let filteredDays: [Int] let yearIndex: Int // Which year this is (0 = most recent) @ObservedObject var demoManager: DemoAnimationManager - let onShare: (UIImage) -> Void + let onShare: ([MoodMetrics], [MoodEntryModel], Int) -> Void private var textColor: Color { theme.currentTheme.labelColor } @@ -519,8 +524,7 @@ struct YearCard: View, Equatable { Spacer() Button(action: { - let image = shareableView.asImage(size: CGSize(width: 400, height: 750)) - onShare(image) + onShare(cachedMetrics, yearEntries, year) }) { Image(systemName: "square.and.arrow.up") .font(.subheadline.weight(.medium))