Files
MobileMusicAssistant/ServicesMAStoreManager.swift

167 lines
5.5 KiB
Swift

//
// 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<String> = [
"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<Void, Never>?
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<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error):
throw error
case .verified(let value):
return value
}
}
private func listenForTransactions() -> Task<Void, Never> {
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)
}
}
}