167 lines
5.5 KiB
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)
|
|
}
|
|
}
|
|
}
|