Add social media share functionality to Month and Year views
- Add share button to MonthCard and YearCard headers - Create social media-friendly "Monthly Mood Wrap" and "Year in Review" designs - Show top mood, days tracked, and mood breakdown with colorful bar charts - Add ImageOnlyShareSheet to share images without extra text - Uses user's selected theme colors and icon pack 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,20 +40,35 @@ class ShareActivityItemSource: NSObject, UIActivityItemSource {
|
|||||||
|
|
||||||
struct ShareSheet: UIViewControllerRepresentable {
|
struct ShareSheet: UIViewControllerRepresentable {
|
||||||
let photo: UIImage
|
let photo: UIImage
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
let text = "ifeel"
|
let text = "ifeel"
|
||||||
let itemSource = ShareActivityItemSource(shareText: text, shareImage: photo)
|
let itemSource = ShareActivityItemSource(shareText: text, shareImage: photo)
|
||||||
|
|
||||||
let activityItems: [Any] = [photo, text, itemSource]
|
let activityItems: [Any] = [photo, text, itemSource]
|
||||||
|
|
||||||
let controller = UIActivityViewController(
|
let controller = UIActivityViewController(
|
||||||
activityItems: activityItems,
|
activityItems: activityItems,
|
||||||
applicationActivities: nil)
|
applicationActivities: nil)
|
||||||
|
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ImageOnlyShareSheet: UIViewControllerRepresentable {
|
||||||
|
let photo: UIImage
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
|
let controller = UIActivityViewController(
|
||||||
|
activityItems: [photo],
|
||||||
|
applicationActivities: nil)
|
||||||
|
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {
|
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ struct MonthView: View {
|
|||||||
)
|
)
|
||||||
selectedDetail.fuckingWrapped = detailView
|
selectedDetail.fuckingWrapped = detailView
|
||||||
selectedDetail.showFuckingSheet = true
|
selectedDetail.showFuckingSheet = true
|
||||||
|
},
|
||||||
|
onShare: { image in
|
||||||
|
shareImage.fuckingWrappedShrable = image
|
||||||
|
shareImage.showFuckingSheet = true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -141,7 +145,7 @@ struct MonthView: View {
|
|||||||
}
|
}
|
||||||
.sheet(isPresented: self.$shareImage.showFuckingSheet) {
|
.sheet(isPresented: self.$shareImage.showFuckingSheet) {
|
||||||
if let uiImage = self.shareImage.fuckingWrappedShrable {
|
if let uiImage = self.shareImage.fuckingWrappedShrable {
|
||||||
ShareSheet(photo: uiImage)
|
ImageOnlyShareSheet(photo: uiImage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onPreferenceChange(ViewOffsetKey.self) { value in
|
.onPreferenceChange(ViewOffsetKey.self) { value in
|
||||||
@@ -169,6 +173,7 @@ struct MonthCard: View {
|
|||||||
let theme: Theme
|
let theme: Theme
|
||||||
let filteredDays: [Int]
|
let filteredDays: [Int]
|
||||||
let onTap: () -> Void
|
let onTap: () -> Void
|
||||||
|
let onShare: (UIImage) -> Void
|
||||||
|
|
||||||
@State private var showStats = true
|
@State private var showStats = true
|
||||||
|
|
||||||
@@ -181,25 +186,146 @@ struct MonthCard: View {
|
|||||||
return Random.createTotalPerc(fromEntries: monthEntries)
|
return Random.createTotalPerc(fromEntries: monthEntries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var topMood: Mood? {
|
||||||
|
metrics.filter { $0.total > 0 }.max(by: { $0.total < $1.total })?.mood
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalTrackedDays: Int {
|
||||||
|
entries.filter { ![.missing, .placeholder].contains($0.mood) }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shareableView: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header with month/year
|
||||||
|
Text("\(Random.monthName(fromMonthInt: month).uppercased()) \(String(year))")
|
||||||
|
.font(.system(size: 32, weight: .heavy, design: .rounded))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.padding(.top, 40)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
Text("Monthly Mood Wrap")
|
||||||
|
.font(.system(size: 16, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(textColor.opacity(0.6))
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
|
||||||
|
// Top mood highlight
|
||||||
|
if let topMood = topMood {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Circle()
|
||||||
|
.fill(moodTint.color(forMood: topMood))
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
.overlay(
|
||||||
|
imagePack.icon(forMood: topMood)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(24)
|
||||||
|
)
|
||||||
|
.shadow(color: moodTint.color(forMood: topMood).opacity(0.5), radius: 20, x: 0, y: 10)
|
||||||
|
|
||||||
|
Text("Top Mood")
|
||||||
|
.font(.system(size: 14, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(textColor.opacity(0.5))
|
||||||
|
|
||||||
|
Text(topMood.strValue.uppercased())
|
||||||
|
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(moodTint.color(forMood: topMood))
|
||||||
|
}
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats row
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("\(totalTrackedDays)")
|
||||||
|
.font(.system(size: 36, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
Text("Days Tracked")
|
||||||
|
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(textColor.opacity(0.5))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
|
||||||
|
// Mood breakdown with bars
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(metrics.filter { $0.total > 0 }.sorted(by: { $0.mood.rawValue > $1.mood.rawValue })) { metric in
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Circle()
|
||||||
|
.fill(moodTint.color(forMood: metric.mood))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.overlay(
|
||||||
|
imagePack.icon(forMood: metric.mood)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(7)
|
||||||
|
)
|
||||||
|
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.gray.opacity(0.2))
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(moodTint.color(forMood: metric.mood))
|
||||||
|
.frame(width: max(8, geo.size.width * CGFloat(metric.percent / 100)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 12)
|
||||||
|
|
||||||
|
Text("\(Int(metric.percent))%")
|
||||||
|
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.frame(width: 40, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
.padding(.bottom, 40)
|
||||||
|
|
||||||
|
// App branding
|
||||||
|
Text("ifeel")
|
||||||
|
.font(.system(size: 14, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(textColor.opacity(0.3))
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
.frame(width: 400)
|
||||||
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Month Header
|
// Month Header
|
||||||
Button(action: { withAnimation(.easeInOut(duration: 0.2)) { showStats.toggle() } }) {
|
HStack {
|
||||||
HStack {
|
Button(action: { withAnimation(.easeInOut(duration: 0.2)) { showStats.toggle() } }) {
|
||||||
Text("\(Random.monthName(fromMonthInt: month)) \(String(year))")
|
HStack {
|
||||||
.font(.title3.bold())
|
Text("\(Random.monthName(fromMonthInt: month)) \(String(year))")
|
||||||
.foregroundColor(textColor)
|
.font(.title3.bold())
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Spacer()
|
Image(systemName: showStats ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
Image(systemName: showStats ? "chevron.up" : "chevron.down")
|
.foregroundColor(textColor.opacity(0.5))
|
||||||
.font(.caption.weight(.semibold))
|
}
|
||||||
.foregroundColor(textColor.opacity(0.5))
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.buttonStyle(.plain)
|
||||||
.padding(.vertical, 12)
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
let image = shareableView.asImage(size: CGSize(width: 400, height: 700))
|
||||||
|
onShare(image)
|
||||||
|
}) {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundColor(textColor.opacity(0.6))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
// Weekday Labels
|
// Weekday Labels
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ struct YearView: View {
|
|||||||
@EnvironmentObject var iapManager: IAPManager
|
@EnvironmentObject var iapManager: IAPManager
|
||||||
@StateObject public var viewModel: YearViewModel
|
@StateObject public var viewModel: YearViewModel
|
||||||
@StateObject private var filteredDays = DaysFilterClass.shared
|
@StateObject private var filteredDays = DaysFilterClass.shared
|
||||||
|
@StateObject private var shareImage = StupidAssShareObservableObject()
|
||||||
@State private var trialWarningHidden = false
|
@State private var trialWarningHidden = false
|
||||||
@State private var showSubscriptionStore = false
|
@State private var showSubscriptionStore = false
|
||||||
|
|
||||||
@@ -46,7 +47,11 @@ struct YearView: View {
|
|||||||
imagePack: imagePack,
|
imagePack: imagePack,
|
||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
filteredDays: filteredDays.currentFilters
|
filteredDays: filteredDays.currentFilters,
|
||||||
|
onShare: { image in
|
||||||
|
shareImage.fuckingWrappedShrable = image
|
||||||
|
shareImage.showFuckingSheet = true
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,6 +100,11 @@ struct YearView: View {
|
|||||||
.sheet(isPresented: $showSubscriptionStore) {
|
.sheet(isPresented: $showSubscriptionStore) {
|
||||||
FeelsSubscriptionStoreView()
|
FeelsSubscriptionStoreView()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $shareImage.showFuckingSheet) {
|
||||||
|
if let uiImage = shareImage.fuckingWrappedShrable {
|
||||||
|
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())
|
||||||
})
|
})
|
||||||
@@ -120,6 +130,7 @@ struct YearCard: View {
|
|||||||
let textColor: Color
|
let textColor: Color
|
||||||
let theme: Theme
|
let theme: Theme
|
||||||
let filteredDays: [Int]
|
let filteredDays: [Int]
|
||||||
|
let onShare: (UIImage) -> Void
|
||||||
|
|
||||||
@State private var showStats = true
|
@State private var showStats = true
|
||||||
|
|
||||||
@@ -140,30 +151,147 @@ struct YearCard: View {
|
|||||||
yearEntries.filter { ![Mood.missing, Mood.placeholder].contains($0.mood) }.count
|
yearEntries.filter { ![Mood.missing, Mood.placeholder].contains($0.mood) }.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var topMood: Mood? {
|
||||||
|
metrics.filter { $0.total > 0 }.max(by: { $0.total < $1.total })?.mood
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shareableView: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header with year
|
||||||
|
Text(String(year))
|
||||||
|
.font(.system(size: 48, weight: .heavy, design: .rounded))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.padding(.top, 40)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
Text("Year in Review")
|
||||||
|
.font(.system(size: 18, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(textColor.opacity(0.6))
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
|
||||||
|
// Top mood highlight
|
||||||
|
if let topMood = topMood {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Circle()
|
||||||
|
.fill(moodTint.color(forMood: topMood))
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
.overlay(
|
||||||
|
imagePack.icon(forMood: topMood)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(28)
|
||||||
|
)
|
||||||
|
.shadow(color: moodTint.color(forMood: topMood).opacity(0.5), radius: 25, x: 0, y: 12)
|
||||||
|
|
||||||
|
Text("Top Mood")
|
||||||
|
.font(.system(size: 14, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(textColor.opacity(0.5))
|
||||||
|
|
||||||
|
Text(topMood.strValue.uppercased())
|
||||||
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(moodTint.color(forMood: topMood))
|
||||||
|
}
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats row
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("\(totalEntries)")
|
||||||
|
.font(.system(size: 42, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
Text("Days Tracked")
|
||||||
|
.font(.system(size: 13, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(textColor.opacity(0.5))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
|
||||||
|
// Mood breakdown with bars
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
ForEach(metrics.filter { $0.total > 0 }.sorted(by: { $0.mood.rawValue > $1.mood.rawValue })) { metric in
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Circle()
|
||||||
|
.fill(moodTint.color(forMood: metric.mood))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.overlay(
|
||||||
|
imagePack.icon(forMood: metric.mood)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(8)
|
||||||
|
)
|
||||||
|
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.gray.opacity(0.2))
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(moodTint.color(forMood: metric.mood))
|
||||||
|
.frame(width: max(8, geo.size.width * CGFloat(metric.percent / 100)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 16)
|
||||||
|
|
||||||
|
Text("\(Int(metric.percent))%")
|
||||||
|
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.frame(width: 45, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
.padding(.bottom, 40)
|
||||||
|
|
||||||
|
// App branding
|
||||||
|
Text("ifeel")
|
||||||
|
.font(.system(size: 14, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(textColor.opacity(0.3))
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
.frame(width: 400)
|
||||||
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Year Header
|
// Year Header
|
||||||
Button(action: { withAnimation(.easeInOut(duration: 0.2)) { showStats.toggle() } }) {
|
HStack {
|
||||||
HStack {
|
Button(action: { withAnimation(.easeInOut(duration: 0.2)) { showStats.toggle() } }) {
|
||||||
Text(String(year))
|
HStack {
|
||||||
.font(.title2.bold())
|
Text(String(year))
|
||||||
.foregroundColor(textColor)
|
.font(.title2.bold())
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Spacer()
|
Text("\(totalEntries) days")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(textColor.opacity(0.6))
|
||||||
|
|
||||||
Text("\(totalEntries) days")
|
Image(systemName: showStats ? "chevron.up" : "chevron.down")
|
||||||
.font(.subheadline)
|
.font(.caption.weight(.semibold))
|
||||||
.foregroundColor(textColor.opacity(0.6))
|
.foregroundColor(textColor.opacity(0.5))
|
||||||
|
.padding(.leading, 4)
|
||||||
Image(systemName: showStats ? "chevron.up" : "chevron.down")
|
}
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundColor(textColor.opacity(0.5))
|
|
||||||
.padding(.leading, 4)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.buttonStyle(.plain)
|
||||||
.padding(.vertical, 12)
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
let image = shareableView.asImage(size: CGSize(width: 400, height: 750))
|
||||||
|
onShare(image)
|
||||||
|
}) {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundColor(textColor.opacity(0.6))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
// Stats Section (collapsible)
|
// Stats Section (collapsible)
|
||||||
if showStats {
|
if showStats {
|
||||||
|
|||||||
Reference in New Issue
Block a user