diff --git a/PortfolioJournal/Services/GoalShareService.swift b/PortfolioJournal/Services/GoalShareService.swift index 712d035..8d8ded1 100644 --- a/PortfolioJournal/Services/GoalShareService.swift +++ b/PortfolioJournal/Services/GoalShareService.swift @@ -1,44 +1,190 @@ import Foundation import SwiftUI import UIKit +import CoreImage.CIFilterBuiltins class GoalShareService { static let shared = GoalShareService() + /// App Store URL - auto-redirects to user's country + static let appStoreURL = URL(string: "https://apps.apple.com/app/portfolio-journal/id6744983373")! + + /// Website URL - has Open Graph tags for social media previews + static let websiteURL = URL(string: "https://portfoliojournal.app")! + + /// Share page base URL - parameters will be appended + static let sharePageBaseURL = "https://portfoliojournal.app/share" + private init() {} + /// Build share URL with goal parameters for dynamic OG tags + static func buildShareURL(goalName: String, progressPercent: Int) -> URL { + var components = URLComponents(string: sharePageBaseURL)! + components.queryItems = [ + URLQueryItem(name: "goal", value: goalName), + URLQueryItem(name: "progress", value: String(progressPercent)) + ] + return components.url ?? URL(string: sharePageBaseURL)! + } + + /// Generate a QR code image for the App Store URL + static func generateQRCode(for url: URL, size: CGFloat = 60) -> UIImage? { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + + filter.message = Data(url.absoluteString.utf8) + filter.correctionLevel = "M" + + guard let outputImage = filter.outputImage else { return nil } + + // Scale up the QR code + let scale = size / outputImage.extent.size.width + let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + + guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { + return nil + } + + return UIImage(cgImage: cgImage) + } + @MainActor func shareGoal( name: String, progress: Double, currentValue: Decimal, - targetValue: Decimal + targetValue: Decimal, + targetDate: Date? = nil, + estimatedCompletionDate: Date? = nil, + privacyMode: Bool = false ) { guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let viewController = windowScene.windows.first?.rootViewController else { return } + // Generate QR code for the card + let qrCodeImage = Self.generateQRCode(for: Self.appStoreURL, size: 200) + let card = GoalShareCardView( name: name, progress: progress, currentValue: currentValue, - targetValue: targetValue + targetValue: targetValue, + targetDate: targetDate, + estimatedCompletionDate: estimatedCompletionDate, + privacyMode: privacyMode, + qrCodeImage: qrCodeImage ) + let progressPercent = Int(progress * 100) + let shareURL = Self.buildShareURL(goalName: name, progressPercent: progressPercent) + if #available(iOS 16.0, *) { let renderer = ImageRenderer(content: card) let scale = viewController.view.window?.windowScene?.screen.scale ?? viewController.traitCollection.displayScale renderer.scale = scale + if let image = renderer.uiImage { - let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil) - viewController.present(activityVC, animated: true) + // Use combined item provider for text + image together + let shareItem = GoalShareItem( + image: image, + goalName: name, + progressPercent: progressPercent, + shareURL: shareURL + ) + presentShareSheet(items: [shareItem], from: viewController) + } else { + // Fallback to text only + let text = buildShareText(goalName: name, progressPercent: progressPercent, shareURL: shareURL) + presentShareSheet(items: [text], from: viewController) } } else { - let text = "I am \(Int(progress * 100))% towards \(name) on Portfolio Journal!" - let activityVC = UIActivityViewController(activityItems: [text], applicationActivities: nil) - viewController.present(activityVC, animated: true) + // iOS 15 fallback - text only + let text = buildShareText(goalName: name, progressPercent: progressPercent, shareURL: shareURL) + presentShareSheet(items: [text], from: viewController) } } + + private func buildShareText(goalName: String, progressPercent: Int, shareURL: URL) -> String { + return """ + I'm \(progressPercent)% towards my "\(goalName)" goal! 🎯 + + Track your investment goals with Portfolio Journal. + \(shareURL.absoluteString) + """ + } + + private func presentShareSheet(items: [Any], from viewController: UIViewController) { + let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil) + + // Exclude some activities that don't make sense for goal sharing + activityVC.excludedActivityTypes = [ + .addToReadingList, + .assignToContact, + .openInIBooks + ] + + // iPad support - prevent crash by setting popover source + if let popover = activityVC.popoverPresentationController { + popover.sourceView = viewController.view + popover.sourceRect = CGRect( + x: viewController.view.bounds.midX, + y: viewController.view.bounds.midY, + width: 0, + height: 0 + ) + popover.permittedArrowDirections = [] + } + + viewController.present(activityVC, animated: true) + } } + +/// Combined share item that provides both image and text together +private class GoalShareItem: NSObject, UIActivityItemSource { + let image: UIImage + let goalName: String + let progressPercent: Int + let shareURL: URL + + init(image: UIImage, goalName: String, progressPercent: Int, shareURL: URL) { + self.image = image + self.goalName = goalName + self.progressPercent = progressPercent + self.shareURL = shareURL + super.init() + } + + func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any { + return image + } + + func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? { + // For most activities, return the image + // The text will be provided via LPLinkMetadata or as a separate item + return image + } + + func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String { + return "My Investment Goal Progress - Portfolio Journal" + } + + func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? { + let metadata = LPLinkMetadata() + metadata.title = "I'm \(progressPercent)% towards my \"\(goalName)\" goal! 🎯" + metadata.originalURL = shareURL + metadata.url = shareURL + metadata.imageProvider = NSItemProvider(object: image) + + // Set icon + if let appIcon = UIImage(named: "BrandMark") { + metadata.iconProvider = NSItemProvider(object: appIcon) + } + + return metadata + } +} + +import LinkPresentation diff --git a/PortfolioJournal/ViewModels/GoalsViewModel.swift b/PortfolioJournal/ViewModels/GoalsViewModel.swift index 4b03617..dd3be04 100644 --- a/PortfolioJournal/ViewModels/GoalsViewModel.swift +++ b/PortfolioJournal/ViewModels/GoalsViewModel.swift @@ -136,7 +136,7 @@ class GoalsViewModel: ObservableObject { goalRepository.deleteGoal(goal) } - private func estimateCompletionDate(for goal: Goal) -> Date? { + func estimateCompletionDate(for goal: Goal) -> Date? { // Performance: Use cached completion date if available if let cached = cachedCompletionDates[goal.id] { return cached diff --git a/PortfolioJournal/Views/Goals/GoalShareCardView.swift b/PortfolioJournal/Views/Goals/GoalShareCardView.swift index f858711..02c1426 100644 --- a/PortfolioJournal/Views/Goals/GoalShareCardView.swift +++ b/PortfolioJournal/Views/Goals/GoalShareCardView.swift @@ -5,22 +5,39 @@ struct GoalShareCardView: View { let progress: Double let currentValue: Decimal let targetValue: Decimal + var targetDate: Date? = nil + var estimatedCompletionDate: Date? = nil + var privacyMode: Bool = false + var qrCodeImage: UIImage? = nil + + private var hasExtraContent: Bool { + targetDate != nil || estimatedCompletionDate != nil + } + + private var cardHeight: CGFloat { + let base: CGFloat = hasExtraContent ? 340 : 290 + return privacyMode ? base + 10 : base + } + + private var displayCurrentValue: String { + privacyMode ? "***" : currentValue.currencyString + } + + private var progressText: String { + let percent = Int((progress * 100).rounded()) + return "\(percent)%" + } var body: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Goal Progress") - .font(.caption.weight(.semibold)) - .foregroundColor(.white.opacity(0.8)) - Text(name) - .font(.title2.weight(.bold)) - .foregroundColor(.white) - } - Spacer() - Image(systemName: "sparkles") - .font(.title2) - .foregroundColor(.white.opacity(0.9)) + VStack(alignment: .leading, spacing: 14) { + // Header with goal name + VStack(alignment: .leading, spacing: 4) { + Text("Goal Progress") + .font(.caption.weight(.semibold)) + .foregroundColor(.white.opacity(0.8)) + Text(name) + .font(.title2.weight(.bold)) + .foregroundColor(.white) } GoalProgressBar( @@ -32,25 +49,118 @@ struct GoalShareCardView: View { ) .frame(height: 10) - HStack { - Text(currentValue.currencyString) - .font(.headline) - .foregroundColor(.white) - Spacer() - Text("of \(targetValue.currencyString)") - .font(.subheadline.weight(.medium)) - .foregroundColor(.white.opacity(0.8)) + Text("\(progressText) complete") + .font(.caption.weight(.semibold)) + .foregroundColor(.white.opacity(0.85)) + + if privacyMode { + HStack { + Text("Progress") + .font(.subheadline.weight(.semibold)) + .foregroundColor(.white.opacity(0.9)) + Spacer() + Text(progressText) + .font(.headline.weight(.bold)) + .foregroundColor(.white) + } + } else { + HStack { + Text(displayCurrentValue) + .font(.headline) + .foregroundColor(.white) + Spacer() + Text("of \(targetValue.currencyString)") + .font(.subheadline.weight(.medium)) + .foregroundColor(.white.opacity(0.8)) + } } - HStack { - Image(systemName: "arrow.up.right") - Text("Track yours in Portfolio Journal") + if let targetDate { + HStack(spacing: 6) { + Image(systemName: "calendar") + .font(.caption) + Text("Target: \(targetDate.mediumDateString)") + .font(.caption.weight(.medium)) + } + .foregroundColor(.white.opacity(0.85)) + } + + if let estimatedCompletionDate { + HStack(spacing: 6) { + Image(systemName: "chart.line.uptrend.xyaxis") + .font(.caption) + Text("Est. completion: \(estimatedCompletionDate.mediumDateString)") + .font(.caption.weight(.medium)) + } + .foregroundColor(.white.opacity(0.85)) + } + + if privacyMode { + HStack(spacing: 6) { + Image(systemName: "eye.slash") + Text("Privacy mode enabled") + } + .font(.caption.weight(.semibold)) + .foregroundColor(.white.opacity(0.7)) + } + + Spacer(minLength: 0) + + // Branding footer with QR code + VStack(spacing: 10) { + // Divider line + Rectangle() + .fill(Color.white.opacity(0.2)) + .frame(height: 1) + + HStack(spacing: 12) { + // App icon and branding + Image("BrandMark") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + + VStack(alignment: .leading, spacing: 2) { + Text("Powered by") + .font(.caption2.weight(.medium)) + .foregroundColor(.white.opacity(0.7)) + Text("Portfolio Journal") + .font(.subheadline.weight(.bold)) + .foregroundColor(.white) + } + + Spacer() + + // QR Code + if let qrCodeImage { + VStack(spacing: 4) { + Image(uiImage: qrCodeImage) + .interpolation(.none) + .resizable() + .frame(width: 50, height: 50) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + + Text("Scan to download") + .font(.system(size: 7, weight: .medium)) + .foregroundColor(.white.opacity(0.8)) + } + } else { + // Fallback if QR code generation fails + VStack(spacing: 2) { + Image(systemName: "qrcode") + .font(.system(size: 28)) + Text("App Store") + .font(.caption2.weight(.semibold)) + } + .foregroundColor(.white.opacity(0.9)) + } + } } - .font(.caption.weight(.semibold)) - .foregroundColor(.white.opacity(0.85)) } - .padding(24) - .frame(width: 320, height: 220) + .padding(20) + .frame(width: 320, height: cardHeight) .background( LinearGradient( colors: [Color.appPrimary, Color.appSecondary], @@ -74,3 +184,24 @@ struct GoalShareCardView: View { targetValue: 1_000_000 ) } + +#Preview("With Dates") { + GoalShareCardView( + name: "1M Goal", + progress: 0.42, + currentValue: 420_000, + targetValue: 1_000_000, + targetDate: Date().addingTimeInterval(365 * 24 * 60 * 60), + estimatedCompletionDate: Date().addingTimeInterval(300 * 24 * 60 * 60) + ) +} + +#Preview("Privacy Mode") { + GoalShareCardView( + name: "1M Goal", + progress: 0.42, + currentValue: 420_000, + targetValue: 1_000_000, + privacyMode: true + ) +} diff --git a/PortfolioJournal/Views/Goals/GoalsView.swift b/PortfolioJournal/Views/Goals/GoalsView.swift index ab710ed..f3b2f70 100644 --- a/PortfolioJournal/Views/Goals/GoalsView.swift +++ b/PortfolioJournal/Views/Goals/GoalsView.swift @@ -21,12 +21,10 @@ struct GoalsView: View { goal: goal, progress: viewModel.progress(for: goal), totalValue: viewModel.totalValue(for: goal), - paceStatus: viewModel.paceStatus(for: goal) + paceStatus: viewModel.paceStatus(for: goal), + estimatedCompletionDate: viewModel.estimateCompletionDate(for: goal), + onEdit: { editingGoal = goal } ) - .contentShape(Rectangle()) - .onTapGesture { - editingGoal = goal - } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { viewModel.deleteGoal(goal) @@ -102,50 +100,78 @@ struct GoalRowView: View { let progress: Double let totalValue: Decimal let paceStatus: GoalPaceStatus? + let estimatedCompletionDate: Date? + let onEdit: () -> Void + + @State private var showingShareOptions = false var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text(goal.name) - .font(.headline) - Spacer() - Button { - GoalShareService.shared.shareGoal( - name: goal.name, - progress: progress, - currentValue: totalValue, - targetValue: goal.targetDecimal - ) - } label: { - Image(systemName: "square.and.arrow.up") - .foregroundColor(.appPrimary) + ZStack(alignment: .topTrailing) { + Button(action: onEdit) { + VStack(alignment: .leading, spacing: 12) { + Text(goal.name) + .font(.headline) + + GoalProgressBar(progress: progress, tint: .appSecondary, iconColor: .appSecondary) + + HStack { + Text(totalValue.currencyString) + .font(.subheadline.weight(.semibold)) + Spacer() + Text("of \(goal.targetDecimal.currencyString)") + .font(.subheadline) + .foregroundColor(.secondary) + } + + if let targetDate = goal.targetDate { + Text("Target date: \(targetDate.mediumDateString)") + .font(.caption) + .foregroundColor(.secondary) + } + + if let paceStatus { + Text(paceStatus.statusText) + .font(.caption.weight(.semibold)) + .foregroundColor(paceStatus.isBehind ? .negativeRed : .positiveGreen) + } } + .frame(maxWidth: .infinity, alignment: .leading) } + .buttonStyle(.plain) - GoalProgressBar(progress: progress, tint: .appSecondary, iconColor: .appSecondary) - - HStack { - Text(totalValue.currencyString) - .font(.subheadline.weight(.semibold)) - Spacer() - Text("of \(goal.targetDecimal.currencyString)") - .font(.subheadline) - .foregroundColor(.secondary) - } - - if let targetDate = goal.targetDate { - Text("Target date: \(targetDate.mediumDateString)") - .font(.caption) - .foregroundColor(.secondary) - } - - if let paceStatus { - Text(paceStatus.statusText) - .font(.caption.weight(.semibold)) - .foregroundColor(paceStatus.isBehind ? .negativeRed : .positiveGreen) + Button { + showingShareOptions = true + } label: { + Image(systemName: "square.and.arrow.up") + .foregroundColor(.appPrimary) + .padding(.top, 2) } + .buttonStyle(.borderless) } .padding(.vertical, 8) + .confirmationDialog("Share Goal", isPresented: $showingShareOptions, titleVisibility: .visible) { + Button("Share with amounts") { + shareGoal(privacyMode: false) + } + Button("Share (privacy mode)") { + shareGoal(privacyMode: true) + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Choose how to share your goal progress") + } + } + + private func shareGoal(privacyMode: Bool) { + GoalShareService.shared.shareGoal( + name: goal.name, + progress: progress, + currentValue: totalValue, + targetValue: goal.targetDecimal, + targetDate: goal.targetDate, + estimatedCompletionDate: estimatedCompletionDate, + privacyMode: privacyMode + ) } } diff --git a/website-cloudflare-worker.js b/website-cloudflare-worker.js new file mode 100644 index 0000000..9e2c5cd --- /dev/null +++ b/website-cloudflare-worker.js @@ -0,0 +1,177 @@ +/** + * Cloudflare Worker for Dynamic Open Graph Tags + * + * Deploy this as a Cloudflare Worker to handle /share routes + * and inject dynamic OG tags based on URL parameters. + * + * URL format: https://portfoliojournal.app/share?goal=My%20Goal&progress=42 + * + * Setup: + * 1. Go to Cloudflare Dashboard > Workers + * 2. Create a new Worker + * 3. Paste this code + * 4. Add a route: portfoliojournal.app/share* + */ + +export default { + async fetch(request) { + const url = new URL(request.url); + + // Only handle /share routes + if (!url.pathname.startsWith('/share')) { + return fetch(request); + } + + // Get parameters + const goalName = url.searchParams.get('goal') || 'Investment Goal'; + const progress = parseInt(url.searchParams.get('progress')) || 0; + + // Build dynamic OG content + const ogTitle = `I'm ${progress}% towards my "${goalName}" goal! 🎯`; + const ogDescription = 'Track your investment goals with Portfolio Journal. Download free on the App Store.'; + const ogImage = 'https://portfoliojournal.app/images/og-share-card.png'; + const appStoreUrl = 'https://apps.apple.com/app/portfolio-journal/id6744983373'; + + const html = ` + + + + + ${escapeHtml(ogTitle)} - Portfolio Journal + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Portfolio Journal + +
+
Goal Progress
+
${escapeHtml(goalName)}
+
+
+
+
${progress}% complete
+
+ +

Portfolio Journal

+

Track your investment goals and celebrate your financial milestones.

+ + + + + + Download Free + + + +
+ +`; + + return new Response(html, { + headers: { + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=3600', // Cache for 1 hour + }, + }); + }, +}; + +function escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return text.replace(/[&<>"']/g, m => map[m]); +} diff --git a/website-og-image-spec.md b/website-og-image-spec.md new file mode 100644 index 0000000..abd73f3 --- /dev/null +++ b/website-og-image-spec.md @@ -0,0 +1,74 @@ +# Open Graph Image Specification + +## Required Image: `og-share-card.png` + +Create this image and place it at: `https://portfoliojournal.app/images/og-share-card.png` + +### Dimensions +- **Size:** 1200 x 630 pixels (Facebook/LinkedIn optimal) +- **Format:** PNG or JPG +- **File size:** Under 1MB recommended + +### Design Recommendations + +``` ++----------------------------------------------------------+ +| | +| [App Icon] | +| | +| Portfolio Journal | +| ───────────────── | +| | +| Track your investment goals | +| and celebrate your wins. | +| | +| [Progress bar illustration] | +| | +| ★ Goal Tracking ★ Smart Predictions | +| ★ Portfolio Analytics ★ Privacy Focused | +| | +| [App Store Badge] | +| | ++----------------------------------------------------------+ +``` + +### Color Scheme +- **Background:** Gradient from #667eea to #764ba2 (match your app branding) +- **Text:** White +- **Accent:** Your app's accent color + +### Elements to Include +1. App icon (prominent, top-left or center) +2. App name "Portfolio Journal" +3. Tagline or value proposition +4. Visual element (progress bar, chart icon, or screenshot) +5. Feature highlights +6. App Store badge (optional) + +### Tools to Create +- Figma (free template available) +- Canva +- Adobe Illustrator/Photoshop + +## Testing Your OG Tags + +After deploying, test with these tools: +- **Facebook:** https://developers.facebook.com/tools/debug/ +- **Twitter:** https://cards-dev.twitter.com/validator +- **LinkedIn:** https://www.linkedin.com/post-inspector/ +- **General:** https://www.opengraph.xyz/ + +## Files to Deploy + +1. `/share/index.html` (or configure your server to serve `share.html` at `/share`) +2. `/images/og-share-card.png` +3. `/images/app-icon.png` (your app icon, 120x120 or larger) + +## Apple Smart App Banner + +The HTML includes this meta tag: +```html + +``` + +This shows a smart banner on iOS Safari prompting users to download your app. diff --git a/website-share-page.html b/website-share-page.html new file mode 100644 index 0000000..64c5a23 --- /dev/null +++ b/website-share-page.html @@ -0,0 +1,254 @@ + + + + + + Portfolio Journal - Goal Progress + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Portfolio Journal + + +
+
Goal Progress
+
Investment Goal
+
+
+
+
0% complete
+
+ +

Portfolio Journal

+

Track your investment goals and celebrate your financial milestones.

+ + + + + + Download Free + + + +
+ + + +