Files
InvestmentTrackerApp/PortfolioJournal/Services/NotificationService.swift
T
alexandrev-tibco 197bddcd7e Growth y monetización: fixes críticos, paywall multi-plan, teasers y App Intents
CRÍTICO — enlaces del App Store rotos (adquisición viral = 0):
- GoalShareService.appStoreURL apuntaba a id6744983373 (404): el QR de TODAS las
  imágenes compartidas llevaba a una página muerta. Corregido a id6757678318.
- SettingsView enlazaba id6741412965 (una novela romántica). Ahora usa la constante.

Monetización:
- IAPService multi-producto: sub anual (com.portfoliojournal.premium.annual, con
  intro offer/trial) + lifetime como ancla. isPremium acepta cualquiera de los dos.
  Paywall con selector de plan (anual por defecto si existe; degrada a solo lifetime
  mientras el producto no esté creado en App Store Connect).
- paywallBenefits: añadidos Multiple Accounts y Family Sharing (diferenciadores).
- Teaser de historia bloqueada en Charts: "N meses más con Premium" en vez de
  truncar en silencio (FreemiumValidator.hiddenHistoryMonths + banner).
- Trigger de paywall de backups ahora instrumentado (logPaywallShown "backups").

Retención / adquisición:
- Rating velocity: prompt tras 2 check-ins y 30 días (antes 3 + 90 — en una app
  mensual eso era >3 meses sin poder pedir la primera review con ~0 ratings).
- Sample data ON por defecto en onboarding (skip ya no aterriza en dashboard vacío).
- Notificación de protección de streak el día 25 si el check-in del mes está pendiente.
- App Intents + AppShortcutsProvider: "Update my portfolio" / "Check my portfolio"
  vía Siri/Shortcuts/Spotlight.
- Instrumentación: onboarding_step/onboarding_skipped y content_shared (viral loop).
- ScreenshotMode (--screenshots) para capturas de marketing automatizadas.

ASO:
- 6 locales nuevos en metadata (en-GB, en-CA, en-AU, es-MX, fr-CA, pt-PT) reusando
  traducciones — 6 campos de keywords extra; pt-PT adaptado (reforma).
- Categoría secundaria: PRODUCTIVITY.
- 17 strings nuevas localizadas en los 7 idiomas.

Pendiente manual: crear la sub anual en App Store Connect (grupo de suscripción +
intro offer 7 días) con el id exacto com.portfoliojournal.premium.annual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-02 22:12:32 +02:00

400 lines
15 KiB
Swift

import Foundation
import Combine
import UserNotifications
import UIKit
class NotificationService: ObservableObject {
static let shared = NotificationService()
@Published var isAuthorized = false
@Published var pendingCount = 0
private let center = UNUserNotificationCenter.current()
private init() {
checkAuthorizationStatus()
}
// MARK: - Authorization
func checkAuthorizationStatus() {
center.getNotificationSettings { [weak self] settings in
DispatchQueue.main.async {
self?.isAuthorized = settings.authorizationStatus == .authorized
}
}
}
func requestAuthorization() async -> Bool {
do {
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
await MainActor.run {
self.isAuthorized = granted
}
if granted {
scheduleMonthlyPerformanceSummary()
}
return granted
} catch {
print("Notification authorization error: \(error)")
return false
}
}
// MARK: - Schedule Notifications
func scheduleReminder(for source: InvestmentSource) {
guard let nextDate = source.nextReminderDate else { return }
guard source.frequency != .never else { return }
// Remove existing notification for this source
cancelReminder(for: source)
// Get notification time from settings
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
let notificationTime = settings.defaultNotificationTime ?? defaultNotificationTime()
// Combine date and time
let calendar = Calendar.current
var components = calendar.dateComponents([.year, .month, .day], from: nextDate)
let timeComponents = calendar.dateComponents([.hour, .minute], from: notificationTime)
components.hour = timeComponents.hour
components.minute = timeComponents.minute
guard let triggerDate = calendar.date(from: components) else { return }
// Create notification content
let content = UNMutableNotificationContent()
content.title = "Investment Update Reminder"
content.body = "Time to update \(source.name). Tap to add a new snapshot."
content.sound = .default
content.badge = NSNumber(value: pendingCount + 1)
content.userInfo = [
"sourceId": source.id.uuidString,
"sourceName": source.name
]
// Create trigger
let triggerComponents = calendar.dateComponents(
[.year, .month, .day, .hour, .minute],
from: triggerDate
)
let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: false)
// Create request
let request = UNNotificationRequest(
identifier: notificationIdentifier(for: source),
content: content,
trigger: trigger
)
// Schedule
center.add(request) { error in
if let error = error {
print("Failed to schedule notification: \(error)")
} else {
print("Scheduled reminder for \(source.name) on \(triggerDate)")
FirebaseService.shared.logNotificationScheduled(frequency: source.notificationFrequency)
}
}
}
func scheduleAllReminders(for sources: [InvestmentSource]) {
for source in sources {
scheduleReminder(for: source)
}
}
// MARK: - Cancel Notifications
func cancelReminder(for source: InvestmentSource) {
center.removePendingNotificationRequests(
withIdentifiers: [notificationIdentifier(for: source)]
)
}
func cancelAllReminders() {
center.removeAllPendingNotificationRequests()
}
// MARK: - Badge Management
func updateBadgeCount() {
let repository = InvestmentSourceRepository()
let needsUpdate = repository.fetchSourcesNeedingUpdate()
pendingCount = needsUpdate.count
center.setBadgeCount(pendingCount) { _ in }
}
func clearBadge() {
pendingCount = 0
center.setBadgeCount(0) { _ in }
}
// MARK: - Pending Notifications
func getPendingNotifications() async -> [UNNotificationRequest] {
await center.pendingNotificationRequests()
}
// MARK: - Helpers
private func notificationIdentifier(for source: InvestmentSource) -> String {
"investment_reminder_\(source.id.uuidString)"
}
private func defaultNotificationTime() -> Date {
var components = DateComponents()
components.hour = 9
components.minute = 0
return Calendar.current.date(from: components) ?? Date()
}
}
// MARK: - Deep Link Handler
extension NotificationService {
func handleNotificationResponse(_ response: UNNotificationResponse) {
let userInfo = response.notification.request.content.userInfo
// Handle batch update deep link from monthly check-in
if let action = userInfo["action"] as? String, action == "batchUpdate" {
NotificationCenter.default.post(name: .openBatchUpdate, object: nil)
return
}
// Handle openDashboard deep link from monthly summary notification
if let action = userInfo["action"] as? String, action == "openDashboard" {
NotificationCenter.default.post(name: .openDashboard, object: nil)
return
}
guard let sourceIdString = userInfo["sourceId"] as? String,
let sourceId = UUID(uuidString: sourceIdString) else {
return
}
// Post notification for deep linking
NotificationCenter.default.post(
name: .openSourceDetail,
object: nil,
userInfo: ["sourceId": sourceId]
)
}
}
// MARK: - Notification Names
extension Notification.Name {
static let openSourceDetail = Notification.Name("openSourceDetail")
static let didResetData = Notification.Name("didResetData")
static let openBatchUpdate = Notification.Name("openBatchUpdate")
static let openDashboard = Notification.Name("openDashboard")
static let openQuickUpdate = Notification.Name("openQuickUpdate")
}
// MARK: - Re-engagement Notifications
extension NotificationService {
/// Schedules a re-engagement notification 7 days from now.
/// Call this every time the app becomes active to reset the timer.
func scheduleReEngagementNotification() {
guard isAuthorized else { return }
center.removePendingNotificationRequests(withIdentifiers: ["re_engagement"])
let content = UNMutableNotificationContent()
content.title = String(localized: "reengagement_title")
content.body = String(localized: "reengagement_body")
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 7 * 24 * 3600, repeats: false)
let request = UNNotificationRequest(identifier: "re_engagement", content: content, trigger: trigger)
center.add(request) { error in
if let error = error {
print("Re-engagement notification error: \(error)")
}
}
}
/// Schedules a non-repeating monthly check-in notification for the 1st of the next month
/// that doesn't have a completed check-in. Safe to call on every app activation.
func scheduleMonthlyCheckIn() {
guard isAuthorized else { return }
let identifier = "monthly_checkin"
center.removePendingNotificationRequests(withIdentifiers: [identifier])
let calendar = Calendar.current
let now = Date()
guard let thisMonthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) else { return }
// Walk forward from next month to find the first month whose check-in is not done
for offset in 1...13 {
guard let targetStart = calendar.date(byAdding: .month, value: offset, to: thisMonthStart) else { break }
let isDone = MonthlyCheckInStore.completionDate(for: targetStart.adding(days: 1)) != nil
if isDone { continue }
var components = calendar.dateComponents([.year, .month], from: targetStart)
components.day = 1
components.hour = 9
components.minute = 0
let content = UNMutableNotificationContent()
content.title = String(localized: "monthly_checkin_notification_title")
content.body = String(localized: "monthly_checkin_notification_body")
content.sound = .default
content.userInfo = ["action": "batchUpdate"]
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
center.add(request) { error in
if let error = error {
print("Monthly check-in notification error: \(error)")
}
}
return
}
}
/// Streak protection: reminds the user on the 25th of the CURRENT month if this
/// month's check-in is still pending loss aversion beats the day-1 nudge alone.
/// Safe to call on every app activation (no-op if already done or date passed).
func scheduleStreakProtectionReminder() {
guard isAuthorized else { return }
let identifier = "streak_protection"
center.removePendingNotificationRequests(withIdentifiers: [identifier])
let calendar = Calendar.current
let now = Date()
guard let thisMonthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) else { return }
// Already checked in this month nothing to protect
guard MonthlyCheckInStore.completionDate(for: thisMonthStart.adding(days: 1)) == nil else { return }
var components = calendar.dateComponents([.year, .month], from: thisMonthStart)
components.day = 25
components.hour = 18
components.minute = 0
guard let fireDate = calendar.date(from: components), fireDate > now else { return }
let content = UNMutableNotificationContent()
content.title = String(localized: "streak_protection_notification_title")
content.body = String(localized: "streak_protection_notification_body")
content.sound = .default
content.userInfo = ["action": "batchUpdate"]
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
center.add(request) { error in
if let error = error {
print("Streak protection notification error: \(error)")
}
}
}
/// Schedules a monthly portfolio summary notification on the 5th of each month at 9am.
func scheduleMonthlyPerformanceSummary() {
guard isAuthorized else { return }
let identifier = "monthly_summary"
center.getPendingNotificationRequests { [weak self] requests in
guard let self, !requests.contains(where: { $0.identifier == identifier }) else { return }
let content = UNMutableNotificationContent()
content.title = String(localized: "monthly_summary_notification_title")
content.body = String(localized: "monthly_summary_notification_body")
content.sound = .default
content.userInfo = ["action": "openDashboard"]
var components = DateComponents()
components.day = 5
components.hour = 9
components.minute = 0
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
self.center.add(request) { error in
if let error = error {
print("Monthly summary notification error: \(error)")
}
}
}
}
/// Fires a one-time local notification celebrating goal achievement.
func scheduleGoalAchievedNotification(goalName: String) {
guard isAuthorized else { return }
let content = UNMutableNotificationContent()
content.title = String(localized: "goal_achieved_notification_title")
content.body = String(format: String(localized: "goal_achieved_notification_body"), goalName)
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let id = "goal_achieved_\(UUID().uuidString)"
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
center.add(request) { error in
if let error = error {
print("Goal achieved notification error: \(error)")
}
}
}
/// Fires a notification when the portfolio crosses a round milestone for the first time.
func checkAndScheduleMilestoneNotification(portfolioValue: Decimal) {
guard isAuthorized else { return }
let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
100000, 250000, 500000, 1_000_000]
let notifiedKey = "lastNotifiedMilestone"
let lastNotified = UserDefaults.standard.double(forKey: notifiedKey)
let current = NSDecimalNumber(decimal: portfolioValue).doubleValue
for milestone in milestones.reversed() {
let ms = NSDecimalNumber(decimal: milestone).doubleValue
if current >= ms {
if ms > lastNotified {
UserDefaults.standard.set(ms, forKey: notifiedKey)
let milestoneStr = CurrencyFormatter.format(milestone, style: .currency, maximumFractionDigits: 0)
let content = UNMutableNotificationContent()
content.title = String(localized: "notification_milestone_title")
content.body = String(format: String(localized: "notification_milestone_body"), milestoneStr)
content.sound = .default
content.userInfo = ["action": "openDashboard"]
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(
identifier: "portfolio_milestone_\(Int(ms))",
content: content,
trigger: trigger
)
center.add(request) { _ in }
}
break
}
}
}
}
// MARK: - Background Refresh
extension NotificationService {
func performBackgroundRefresh() {
updateBadgeCount()
// Reschedule any missed notifications
let repository = InvestmentSourceRepository()
let sources = repository.fetchActiveSources()
scheduleAllReminders(for: sources)
}
}