1.1.0 feature work: Monthly Check-in, Charts, Goals, Share, Reviews
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ final class ReviewPromptService {
|
||||
|
||||
private let lastPromptKey = "reviewPromptLastDate"
|
||||
private let checkInCountKey = "reviewPromptCheckinCount"
|
||||
private let hasCompletedStoreReviewKey = "reviewPromptHasCompletedStoreReview"
|
||||
private let promptedAchievementKeysKey = "reviewPromptedAchievementKeys"
|
||||
private let minCheckInsBetweenPrompts = 3
|
||||
private let minDaysBetweenPrompts = 90
|
||||
private let userDefaults: UserDefaults
|
||||
@@ -34,7 +36,40 @@ final class ReviewPromptService {
|
||||
requestReviewIfEligible()
|
||||
}
|
||||
|
||||
func shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: Set<String>) -> Bool {
|
||||
guard !newlyUnlockedAchievementKeys.isEmpty else { return false }
|
||||
guard !hasCompletedStoreReview else { return false }
|
||||
|
||||
let promptedKeys = Set(userDefaults.stringArray(forKey: promptedAchievementKeysKey) ?? [])
|
||||
let unpromptedKeys = newlyUnlockedAchievementKeys.subtracting(promptedKeys)
|
||||
guard !unpromptedKeys.isEmpty else { return false }
|
||||
|
||||
let mergedKeys = promptedKeys.union(unpromptedKeys).sorted()
|
||||
userDefaults.set(mergedKeys, forKey: promptedAchievementKeysKey)
|
||||
return true
|
||||
}
|
||||
|
||||
var hasCompletedStoreReview: Bool {
|
||||
userDefaults.bool(forKey: hasCompletedStoreReviewKey)
|
||||
}
|
||||
|
||||
func markStoreReviewCompleted() {
|
||||
userDefaults.set(true, forKey: hasCompletedStoreReviewKey)
|
||||
}
|
||||
|
||||
static func appStoreWriteReviewURL() -> URL {
|
||||
guard var components = URLComponents(url: GoalShareService.appStoreURL, resolvingAgainstBaseURL: false) else {
|
||||
return GoalShareService.appStoreURL
|
||||
}
|
||||
var queryItems = components.queryItems ?? []
|
||||
queryItems.removeAll { $0.name == "action" }
|
||||
queryItems.append(URLQueryItem(name: "action", value: "write-review"))
|
||||
components.queryItems = queryItems
|
||||
return components.url ?? GoalShareService.appStoreURL
|
||||
}
|
||||
|
||||
private func requestReviewIfEligible() {
|
||||
guard !hasCompletedStoreReview else { return }
|
||||
let now = dateProvider()
|
||||
if let lastPrompt = userDefaults.object(forKey: lastPromptKey) as? Date {
|
||||
let daysSince = now.timeIntervalSince(lastPrompt) / 86_400
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import LinkPresentation
|
||||
|
||||
class ShareService {
|
||||
static let shared = ShareService()
|
||||
@@ -18,6 +20,81 @@ class ShareService {
|
||||
"""
|
||||
}
|
||||
|
||||
static func buildPortfolioValueShareText(
|
||||
totalValue: String,
|
||||
changeText: String,
|
||||
changeLabel: String,
|
||||
yearChange: String?,
|
||||
sinceInceptionChange: String?,
|
||||
appName: String
|
||||
) -> String {
|
||||
var lines = [
|
||||
"Total Portfolio Value",
|
||||
totalValue,
|
||||
"\(changeText) \(changeLabel)"
|
||||
]
|
||||
|
||||
if let yearChange {
|
||||
lines.append("YoY: \(yearChange)")
|
||||
}
|
||||
if let sinceInceptionChange {
|
||||
lines.append("Since inception: \(sinceInceptionChange)")
|
||||
}
|
||||
|
||||
lines.append("")
|
||||
lines.append("Shared from \(appName)")
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func shareMonthlyCheckIn(summary: MonthlySummary, appName: String) {
|
||||
let text = Self.buildMonthlyCheckInShareText(summary: summary, appName: appName)
|
||||
shareCard(
|
||||
cardTitle: "\(summary.periodLabel) Check-in",
|
||||
fallbackText: text
|
||||
) {
|
||||
MonthlyCheckInShareCardView(
|
||||
summary: summary,
|
||||
appName: appName,
|
||||
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sharePortfolioValue(
|
||||
totalValue: String,
|
||||
changeText: String,
|
||||
changeLabel: String,
|
||||
yearChange: String?,
|
||||
sinceInceptionChange: String?
|
||||
) {
|
||||
let appName = Self.appDisplayName
|
||||
let text = Self.buildPortfolioValueShareText(
|
||||
totalValue: totalValue,
|
||||
changeText: changeText,
|
||||
changeLabel: changeLabel,
|
||||
yearChange: yearChange,
|
||||
sinceInceptionChange: sinceInceptionChange,
|
||||
appName: appName
|
||||
)
|
||||
|
||||
shareCard(
|
||||
cardTitle: "Portfolio Snapshot",
|
||||
fallbackText: text
|
||||
) {
|
||||
PortfolioValueShareCardView(
|
||||
totalValue: totalValue,
|
||||
changeText: changeText,
|
||||
changeLabel: changeLabel,
|
||||
yearChange: yearChange,
|
||||
sinceInceptionChange: sinceInceptionChange,
|
||||
appName: appName,
|
||||
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func shareText(_ content: String) {
|
||||
guard let viewController = ShareService.topViewController() else { return }
|
||||
|
||||
@@ -41,6 +118,36 @@ class ShareService {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func shareCard<Content: View>(
|
||||
cardTitle: String,
|
||||
fallbackText: String,
|
||||
@ViewBuilder card: () -> Content
|
||||
) {
|
||||
guard let viewController = ShareService.topViewController() else { return }
|
||||
let shareURL = GoalShareService.appStoreURL
|
||||
|
||||
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 item = CardShareItem(
|
||||
image: image,
|
||||
title: cardTitle,
|
||||
text: fallbackText,
|
||||
url: shareURL
|
||||
)
|
||||
presentShareSheet(items: [item], from: viewController)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
presentShareSheet(items: [fallbackText], from: viewController)
|
||||
}
|
||||
|
||||
func shareTextFile(content: String, fileName: String) {
|
||||
guard let viewController = ShareService.topViewController() else { return }
|
||||
|
||||
@@ -144,4 +251,67 @@ class ShareService {
|
||||
.replacingOccurrences(of: ";", with: "\\;")
|
||||
.replacingOccurrences(of: ",", with: "\\,")
|
||||
}
|
||||
|
||||
private func presentShareSheet(items: [Any], from viewController: UIViewController) {
|
||||
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
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)
|
||||
}
|
||||
|
||||
private static var appDisplayName: String {
|
||||
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String {
|
||||
return name
|
||||
}
|
||||
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String {
|
||||
return name
|
||||
}
|
||||
return "Portfolio Journal"
|
||||
}
|
||||
}
|
||||
|
||||
private class CardShareItem: NSObject, UIActivityItemSource {
|
||||
let image: UIImage
|
||||
let title: String
|
||||
let text: String
|
||||
let url: URL
|
||||
|
||||
init(image: UIImage, title: String, text: String, url: URL) {
|
||||
self.image = image
|
||||
self.title = title
|
||||
self.text = text
|
||||
self.url = url
|
||||
}
|
||||
|
||||
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
|
||||
image
|
||||
}
|
||||
|
||||
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
||||
image
|
||||
}
|
||||
|
||||
func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String {
|
||||
title
|
||||
}
|
||||
|
||||
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
|
||||
let metadata = LPLinkMetadata()
|
||||
metadata.title = title
|
||||
metadata.originalURL = url
|
||||
metadata.url = url
|
||||
metadata.imageProvider = NSItemProvider(object: image)
|
||||
if let appIcon = UIImage(named: "BrandMark") {
|
||||
metadata.iconProvider = NSItemProvider(object: appIcon)
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user