// // ServicesMAStoreManager.swift // Mobile Music Assistant // // Created by Sven Hanold on 09.04.26. // import StoreKit import OSLog import SwiftUI private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "StoreManager") enum PurchaseResult: Equatable { case success(Product) case cancelled case failed(String) } @Observable @MainActor final class MAStoreManager { var products: [Product] = [] var purchaseResult: PurchaseResult? var isPurchasing = false var loadError: String? private static let productIDs: Set = [ "donatesong", "donatealbum", "donateanthology" ] // MARK: - Nudge / Supporter tracking keys private static let firstLaunchKey = "ma_firstLaunchDate" private static let lastKeyDateKey = "ma_lastNudgeOrPurchaseDate" private static let hasEverSupportedKey = "ma_hasEverSupported" private let defaults = UserDefaults.standard private var updateListenerTask: Task? init() { // Persist first-launch date on very first run _ = firstLaunchDate updateListenerTask = listenForTransactions() } // MARK: - Supporter state /// True once the user has completed at least one donation. var hasEverSupported: Bool { defaults.bool(forKey: Self.hasEverSupportedKey) } // MARK: - Nudge logic /// The date the app was first launched (written once, then read-only). var firstLaunchDate: Date { if let date = defaults.object(forKey: Self.firstLaunchKey) as? Date { return date } let now = Date() defaults.set(now, forKey: Self.firstLaunchKey) return now } /// True when the nudge sheet should be presented. /// Rules: /// - First show: ≥ 3 days after install and never shown/purchased before. /// - Repeat: ≥ 6 months since last nudge dismissal OR last purchase. var shouldShowNudge: Bool { let threeDays: TimeInterval = 3 * 24 * 3600 let sixMonths: TimeInterval = 6 * 30 * 24 * 3600 guard Date().timeIntervalSince(firstLaunchDate) >= threeDays else { return false } guard let last = defaults.object(forKey: Self.lastKeyDateKey) as? Date else { return true } return Date().timeIntervalSince(last) >= sixMonths } /// Call when the nudge sheet is dismissed (regardless of purchase outcome). func recordNudgeShown() { defaults.set(Date(), forKey: Self.lastKeyDateKey) } /// Records a successful purchase: sets supporter flag and resets the nudge clock. private func recordPurchase() { defaults.set(true, forKey: Self.hasEverSupportedKey) defaults.set(Date(), forKey: Self.lastKeyDateKey) } func loadProducts() async { loadError = nil do { let fetched = try await Product.products(for: Self.productIDs) products = fetched.sorted { $0.price < $1.price } if fetched.isEmpty { loadError = "No products returned. Make sure the StoreKit configuration is active in the scheme (Edit Scheme → Run → Options → StoreKit Configuration)." logger.warning("Product.products(for:) returned 0 results for IDs: \(Self.productIDs)") } } catch { loadError = error.localizedDescription logger.error("Failed to load products: \(error.localizedDescription)") } } func purchase(_ product: Product) async { isPurchasing = true defer { isPurchasing = false } do { let result = try await product.purchase() switch result { case .success(let verification): let transaction = try checkVerified(verification) await transaction.finish() recordPurchase() purchaseResult = .success(product) case .userCancelled: purchaseResult = .cancelled case .pending: break @unknown default: break } } catch { purchaseResult = .failed(error.localizedDescription) } } private func checkVerified(_ result: VerificationResult) throws -> T { switch result { case .unverified(_, let error): throw error case .verified(let value): return value } } private func listenForTransactions() -> Task { Task(priority: .background) { [weak self] in for await result in Transaction.updates { do { let transaction = try self?.checkVerified(result) await transaction?.finish() } catch { logger.error("Transaction verification failed: \(error.localizedDescription)") } } } } // MARK: - Helpers func iconName(for product: Product) -> String { switch product.id { case "donatesong": return "music.note" case "donatealbum": return "opticaldisc" case "donateanthology": return "music.note.list" default: return "heart.fill" } } func tierName(for product: Product) -> LocalizedStringKey { switch product.id { case "donatesong": return "Song" case "donatealbum": return "Album" case "donateanthology": return "Anthology" default: return LocalizedStringKey(product.displayName) } } }