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 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ struct MonthView: View {
|
|||||||
@AppStorage(UserDefaultsStore.Keys.shape.rawValue, store: GroupUserDefaults.groupDefaults) private var shape: BGShape = .circle
|
@AppStorage(UserDefaultsStore.Keys.shape.rawValue, store: GroupUserDefaults.groupDefaults) private var shape: BGShape = .circle
|
||||||
|
|
||||||
@StateObject private var shareImage = ShareImageStateViewModel()
|
@StateObject private var shareImage = ShareImageStateViewModel()
|
||||||
|
@State private var sharePickerData: SharePickerData? = nil
|
||||||
|
|
||||||
@EnvironmentObject var iapManager: IAPManager
|
@EnvironmentObject var iapManager: IAPManager
|
||||||
@StateObject private var selectedDetail = DetailViewStateViewModel()
|
@StateObject private var selectedDetail = DetailViewStateViewModel()
|
||||||
@@ -192,9 +193,22 @@ struct MonthView: View {
|
|||||||
selectedDetail.selectedItem = detailView
|
selectedDetail.selectedItem = detailView
|
||||||
selectedDetail.showSheet = true
|
selectedDetail.showSheet = true
|
||||||
},
|
},
|
||||||
onShare: { image in
|
onShare: { metrics, entries, month in
|
||||||
shareImage.selectedShareImage = image
|
sharePickerData = SharePickerData(
|
||||||
shareImage.showSheet = true
|
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)")
|
.id("month-\(monthIndex)")
|
||||||
@@ -337,10 +351,8 @@ struct MonthView: View {
|
|||||||
onDismiss: didDismiss) {
|
onDismiss: didDismiss) {
|
||||||
selectedDetail.selectedItem
|
selectedDetail.selectedItem
|
||||||
}
|
}
|
||||||
.sheet(isPresented: self.$shareImage.showSheet) {
|
.sheet(item: $sharePickerData) { data in
|
||||||
if let uiImage = self.shareImage.selectedShareImage {
|
SharingStylePickerView(title: data.title, designs: data.designs)
|
||||||
ImageOnlyShareSheet(photo: uiImage)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onPreferenceChange(ViewOffsetKey.self) { value in
|
.onPreferenceChange(ViewOffsetKey.self) { value in
|
||||||
withAnimation {
|
withAnimation {
|
||||||
@@ -349,15 +361,7 @@ struct MonthView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
cachedSortedData = computeSortedYearMonthData()
|
cachedSortedData = computeSortedYearMonthData()
|
||||||
#if DEBUG
|
// Demo mode is toggled manually via triple-tap
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.numberOfItems) { _, _ in
|
.onChange(of: viewModel.numberOfItems) { _, _ in
|
||||||
// Use numberOfItems as a lightweight proxy for data changes
|
// Use numberOfItems as a lightweight proxy for data changes
|
||||||
@@ -398,7 +402,7 @@ struct MonthCard: View, Equatable {
|
|||||||
let filteredDays: [Int]
|
let filteredDays: [Int]
|
||||||
let monthIndex: Int // Index for demo animation sequencing
|
let monthIndex: Int // Index for demo animation sequencing
|
||||||
let onTap: () -> Void
|
let onTap: () -> Void
|
||||||
let onShare: (UIImage) -> Void
|
let onShare: ([MoodMetrics], [MoodEntryModel], Int) -> Void
|
||||||
|
|
||||||
private var labelColor: Color { theme.currentTheme.labelColor }
|
private var labelColor: Color { theme.currentTheme.labelColor }
|
||||||
|
|
||||||
@@ -587,8 +591,7 @@ struct MonthCard: View, Equatable {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
let image = shareableView.asImage(size: CGSize(width: 400, height: 700))
|
onShare(cachedMetrics, entries, month)
|
||||||
onShare(image)
|
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
|
|||||||
@@ -37,54 +37,102 @@ struct SharingListView: View {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
init() {
|
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 = [
|
self.sharebleItems = [
|
||||||
WrappedSharable(preview: AnyView(
|
// All Time Moods — style picker with 2 variations
|
||||||
AllMoodsTotalTemplate(isPreview: true,
|
WrappedSharable(
|
||||||
startDate: DataController.shared.earliestEntry?.forDate ?? Date(),
|
preview: AnyView(
|
||||||
endDate: Date(),
|
AllMoodsTotalTemplate(isPreview: true, startDate: earliestDate, endDate: now, fakeData: false)
|
||||||
fakeData: false)
|
),
|
||||||
),destination: AnyView(
|
destination: AnyView(
|
||||||
AllMoodsTotalTemplate(isPreview: false,
|
SharingStylePickerView(title: "All Time Moods", designs: [
|
||||||
startDate: DataController.shared.earliestEntry?.forDate ?? Date(),
|
SharingDesign(
|
||||||
endDate: Date(),
|
name: "Gradient",
|
||||||
fakeData: false)
|
shareView: AnyView(AllMoodsV2(metrics: allMoodsMetrics, totalCount: allMoodsCount)),
|
||||||
),description: AllMoodsTotalTemplate.description),
|
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(
|
// Last 10 Days — style picker with 2 variations
|
||||||
CurrentStreakTemplate(isPreview: true,
|
WrappedSharable(
|
||||||
startDate: Calendar.current.date(byAdding: .day, value: -10, to: Date())!,
|
preview: AnyView(
|
||||||
endDate: Date(),
|
CurrentStreakTemplate(isPreview: true, startDate: tenDaysAgo, endDate: now, fakeData: false)
|
||||||
fakeData: false)
|
),
|
||||||
), destination: AnyView(
|
destination: AnyView(
|
||||||
CurrentStreakTemplate(isPreview: false,
|
SharingStylePickerView(title: "Last 10 Days", designs: [
|
||||||
startDate: Calendar.current.date(byAdding: .day, value: -10, to: Date())!,
|
SharingDesign(
|
||||||
endDate: Date(),
|
name: "Gradient Cards",
|
||||||
fakeData: false)
|
shareView: AnyView(CurrentStreakV2(moodEntries: streakEntries)),
|
||||||
), description: CurrentStreakTemplate.description),
|
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(
|
// Longest Streak — custom picker with mood selection
|
||||||
LongestStreakTemplate(isPreview: true,
|
WrappedSharable(
|
||||||
startDate: DataController.shared.earliestEntry?.forDate ?? Date(),
|
preview: AnyView(
|
||||||
endDate: Date(),
|
LongestStreakTemplate(isPreview: true, startDate: earliestDate, endDate: now, fakeData: false)
|
||||||
fakeData: false)
|
),
|
||||||
), destination: AnyView(
|
destination: AnyView(
|
||||||
LongestStreakTemplate(isPreview: false,
|
LongestStreakPickerView(startDate: earliestDate, endDate: now)
|
||||||
startDate: DataController.shared.earliestEntry?.forDate ?? Date(),
|
),
|
||||||
endDate: Date(),
|
description: LongestStreakTemplate.description
|
||||||
fakeData: false)
|
),
|
||||||
), description: LongestStreakTemplate.description),
|
|
||||||
//////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////
|
||||||
WrappedSharable(preview: AnyView(
|
// This Month — style picker with 2 variations
|
||||||
MonthTotalTemplate(isPreview: true,
|
WrappedSharable(
|
||||||
startDate: Date().startOfMonth,
|
preview: AnyView(
|
||||||
endDate: Date().endOfMonth,
|
MonthTotalTemplate(isPreview: true, startDate: monthStart, endDate: monthEnd, fakeData: false)
|
||||||
fakeData: false)
|
),
|
||||||
), destination: AnyView(
|
destination: AnyView(
|
||||||
MonthTotalTemplate(isPreview: false,
|
SharingStylePickerView(title: "This Month", designs: [
|
||||||
startDate: Date().startOfMonth,
|
SharingDesign(
|
||||||
endDate: Date().endOfMonth,
|
name: "Clean Calendar",
|
||||||
fakeData: false)
|
shareView: AnyView(MonthTotalV1(moodMetrics: monthMetrics, moodEntries: monthEntries, month: month)),
|
||||||
), description: MonthTotalTemplate.description)
|
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
|
||||||
|
),
|
||||||
//////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
267
Shared/Views/Sharing/SharingStylePickerView.swift
Normal file
267
Shared/Views/Sharing/SharingStylePickerView.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ struct YearView: View {
|
|||||||
@StateObject private var shareImage = ShareImageStateViewModel()
|
@StateObject private var shareImage = ShareImageStateViewModel()
|
||||||
@State private var trialWarningHidden = false
|
@State private var trialWarningHidden = false
|
||||||
@State private var showSubscriptionStore = 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
|
/// Cached sorted year keys to avoid re-sorting in ForEach on every render
|
||||||
@State private var cachedSortedYearKeys: [Int] = []
|
@State private var cachedSortedYearKeys: [Int] = []
|
||||||
@@ -146,9 +147,23 @@ struct YearView: View {
|
|||||||
filteredDays: filteredDays.currentFilters,
|
filteredDays: filteredDays.currentFilters,
|
||||||
yearIndex: yearIndex,
|
yearIndex: yearIndex,
|
||||||
demoManager: demoManager,
|
demoManager: demoManager,
|
||||||
onShare: { image in
|
onShare: { metrics, entries, year in
|
||||||
shareImage.selectedShareImage = image
|
let totalCount = entries.filter { ![.missing, .placeholder].contains($0.mood) }.count
|
||||||
shareImage.showSheet = true
|
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) {
|
.sheet(isPresented: $showSubscriptionStore) {
|
||||||
FeelsSubscriptionStoreView()
|
FeelsSubscriptionStoreView()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $shareImage.showSheet) {
|
.sheet(item: $sharePickerData) { data in
|
||||||
if let uiImage = shareImage.selectedShareImage {
|
SharingStylePickerView(title: data.title, designs: data.designs)
|
||||||
ImageOnlyShareSheet(photo: uiImage)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onAppear(perform: {
|
.onAppear(perform: {
|
||||||
self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date())
|
self.viewModel.filterEntries(startDate: Date(timeIntervalSince1970: 0), endDate: Date())
|
||||||
cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >))
|
cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >))
|
||||||
#if DEBUG
|
// Demo mode is toggled manually via triple-tap
|
||||||
// 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
|
|
||||||
})
|
})
|
||||||
.onChange(of: viewModel.data.keys.count) { _, _ in
|
.onChange(of: viewModel.data.keys.count) { _, _ in
|
||||||
cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >))
|
cachedSortedYearKeys = Array(viewModel.data.keys.sorted(by: >))
|
||||||
@@ -321,7 +326,7 @@ struct YearCard: View, Equatable {
|
|||||||
let filteredDays: [Int]
|
let filteredDays: [Int]
|
||||||
let yearIndex: Int // Which year this is (0 = most recent)
|
let yearIndex: Int // Which year this is (0 = most recent)
|
||||||
@ObservedObject var demoManager: DemoAnimationManager
|
@ObservedObject var demoManager: DemoAnimationManager
|
||||||
let onShare: (UIImage) -> Void
|
let onShare: ([MoodMetrics], [MoodEntryModel], Int) -> Void
|
||||||
|
|
||||||
private var textColor: Color { theme.currentTheme.labelColor }
|
private var textColor: Color { theme.currentTheme.labelColor }
|
||||||
|
|
||||||
@@ -519,8 +524,7 @@ struct YearCard: View, Equatable {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
let image = shareableView.asImage(size: CGSize(width: 400, height: 750))
|
onShare(cachedMetrics, yearEntries, year)
|
||||||
onShare(image)
|
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
|
|||||||
Reference in New Issue
Block a user