From 7e25a4f978b2492a42faa9e99452899e106396b4 Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 20 Apr 2026 11:11:24 +0200 Subject: [PATCH] Live Activities, nudging, unit tests --- Donations.storekit | 4 +- .../project.pbxproj | 124 ++++++++++++++++++ .../xcschemes/Mobile Music Assistant.xcscheme | 27 ++++ Mobile Music Assistant/Localizable.xcstrings | 67 +++++++++- .../ServicesMAPlayerManager.swift | 8 ++ .../ServicesMAWebSocketClient.swift | 1 + Mobile Music Assistant/ViewsMainTabView.swift | 33 +++++ Mobile Music Assistant/ViewsRootView.swift | 19 +++ MobileMAShared/Package.swift | 2 +- .../MALibraryManagerTests.swift | 22 ++-- MobileMusicAssistantTests/MAModelsTests.swift | 4 +- .../MAPlayerManagerTests.swift | 1 + .../MAStoreManagerTests.swift | 1 + .../MAWebSocketClientTests.swift | 6 +- ServicesMAStoreManager.swift | 49 +++++++ 15 files changed, 348 insertions(+), 20 deletions(-) diff --git a/Donations.storekit b/Donations.storekit index 893c69e..c9f9205 100644 --- a/Donations.storekit +++ b/Donations.storekit @@ -55,7 +55,7 @@ "type" : "Consumable" }, { - "displayPrice" : "0.99", + "displayPrice" : "2.99", "familyShareable" : false, "internalID" : "6761880625", "localizations" : [ @@ -83,7 +83,7 @@ "_developerTeamID" : "EKFHUHT63T", "_disableDialogs" : false, "_failTransactionsEnabled" : false, - "_lastSynchronizedDate" : 797413193.48934102, + "_lastSynchronizedDate" : 798368909.15410197, "_locale" : "en_US", "_renewalBillingIssuesEnabled" : false, "_storefront" : "USA", diff --git a/Mobile Music Assistant.xcodeproj/project.pbxproj b/Mobile Music Assistant.xcodeproj/project.pbxproj index ef9e33c..bf1900f 100644 --- a/Mobile Music Assistant.xcodeproj/project.pbxproj +++ b/Mobile Music Assistant.xcodeproj/project.pbxproj @@ -16,8 +16,19 @@ 26A83F532F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */; }; 26DA6F7F2F928B2100849EC7 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26DA6F7E2F928B2100849EC7 /* WidgetKit.framework */; }; 26DA6F812F928B2100849EC7 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26DA6F802F928B2100849EC7 /* SwiftUI.framework */; }; + 90E974264D154BE8994F9E4F /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C856F1982B73404D8539D5F6 /* XCTest.framework */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 7E0336539BFF46B697265C50 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 26ED92592F759EEA0025419D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 26ED92602F759EEA0025419D; + remoteInfo = "Mobile Music Assistant"; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 269ECE592F92A21100444B14 /* CopyFiles */ = { isa = PBXCopyFilesBuildPhase; @@ -41,6 +52,8 @@ 26DA6F7E2F928B2100849EC7 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 26DA6F802F928B2100849EC7 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 26ED92612F759EEA0025419D /* Mobile Music Assistant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mobile Music Assistant.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + B8A2C9310F1D4E7B8C456DEF /* MobileMusicAssistantTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MobileMusicAssistantTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + C856F1982B73404D8539D5F6 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -75,6 +88,11 @@ path = "Mobile Music Assistant"; sourceTree = ""; }; + 470E2652A47C438395161BBE /* MobileMusicAssistantTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = MobileMusicAssistantTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -96,9 +114,25 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F61F7920CF95441B8DBFD240 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 90E974264D154BE8994F9E4F /* XCTest.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 26D648CD2F961C3D0095ADFE /* Recovered References */ = { + isa = PBXGroup; + children = ( + C856F1982B73404D8539D5F6 /* XCTest.framework */, + ); + name = "Recovered References"; + sourceTree = ""; + }; 26DA6F7D2F928B2100849EC7 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -120,6 +154,8 @@ 26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */, 2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */, 2616AF4D2F876BEF00CB210E /* ServicesMAStoreManager.swift */, + 470E2652A47C438395161BBE /* MobileMusicAssistantTests */, + 26D648CD2F961C3D0095ADFE /* Recovered References */, ); sourceTree = ""; }; @@ -128,6 +164,7 @@ children = ( 26ED92612F759EEA0025419D /* Mobile Music Assistant.app */, 26DA6F7C2F928B2100849EC7 /* MobileMALiveActivityExtension.appex */, + B8A2C9310F1D4E7B8C456DEF /* MobileMusicAssistantTests.xctest */, ); name = Products; sourceTree = ""; @@ -135,6 +172,27 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 238344CE1A9D4B65ADFC4985 /* MobileMusicAssistantTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 141B93AED5AC40528B942A54 /* Build configuration list for PBXNativeTarget "MobileMusicAssistantTests" */; + buildPhases = ( + 542FA6B14D1B439B91514A5B /* Sources */, + F61F7920CF95441B8DBFD240 /* Frameworks */, + E9CACA4F05AF45868831E179 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 506F6578918A4D6CA740E1E5 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 470E2652A47C438395161BBE /* MobileMusicAssistantTests */, + ); + name = MobileMusicAssistantTests; + productName = MobileMusicAssistantTests; + productReference = B8A2C9310F1D4E7B8C456DEF /* MobileMusicAssistantTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 26DA6F7B2F928B2100849EC7 /* MobileMALiveActivityExtension */ = { isa = PBXNativeTarget; buildConfigurationList = 26DA6F912F928B2200849EC7 /* Build configuration list for PBXNativeTarget "MobileMALiveActivityExtension" */; @@ -192,6 +250,10 @@ LastSwiftUpdateCheck = 2640; LastUpgradeCheck = 2640; TargetAttributes = { + 238344CE1A9D4B65ADFC4985 = { + CreatedOnToolsVersion = 26.4; + TestTargetID = 26ED92602F759EEA0025419D; + }; 26DA6F7B2F928B2100849EC7 = { CreatedOnToolsVersion = 26.4; }; @@ -222,6 +284,7 @@ targets = ( 26ED92602F759EEA0025419D /* Mobile Music Assistant */, 26DA6F7B2F928B2100849EC7 /* MobileMALiveActivityExtension */, + 238344CE1A9D4B65ADFC4985 /* MobileMusicAssistantTests */, ); }; /* End PBXProject section */ @@ -242,6 +305,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E9CACA4F05AF45868831E179 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -262,9 +332,54 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 542FA6B14D1B439B91514A5B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 506F6578918A4D6CA740E1E5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 26ED92602F759EEA0025419D /* Mobile Music Assistant */; + targetProxy = 7E0336539BFF46B697265C50 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 0492C8936D97405C87FB61FC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant.Tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mobile Music Assistant.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Mobile Music Assistant"; + }; + name = Release; + }; + 2320FB54202843A595CB0E32 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + PRODUCT_BUNDLE_IDENTIFIER = "Team.Mobile-Music-Assistant.Tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mobile Music Assistant.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Mobile Music Assistant"; + }; + name = Debug; + }; 26DA6F8E2F928B2200849EC7 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -529,6 +644,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 141B93AED5AC40528B942A54 /* Build configuration list for PBXNativeTarget "MobileMusicAssistantTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2320FB54202843A595CB0E32 /* Debug */, + 0492C8936D97405C87FB61FC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 26DA6F912F928B2200849EC7 /* Build configuration list for PBXNativeTarget "MobileMALiveActivityExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Mobile Music Assistant.xcodeproj/xcshareddata/xcschemes/Mobile Music Assistant.xcscheme b/Mobile Music Assistant.xcodeproj/xcshareddata/xcschemes/Mobile Music Assistant.xcscheme index bbb4e3e..fc004b3 100644 --- a/Mobile Music Assistant.xcodeproj/xcshareddata/xcschemes/Mobile Music Assistant.xcscheme +++ b/Mobile Music Assistant.xcodeproj/xcshareddata/xcschemes/Mobile Music Assistant.xcscheme @@ -21,6 +21,20 @@ ReferencedContainer = "container:Mobile Music Assistant.xcodeproj"> + + + + + + + + + + Data? { resizeAndEncode(image) } + private static func resizeAndEncode(_ image: UIImage) -> Data? { // ActivityKit ContentState limit is 4 KB total (Data fields are base64 in the payload). // 40×40 JPEG at 0.3 quality ≈ 400–700 bytes, well within limits. diff --git a/Mobile Music Assistant/ServicesMAWebSocketClient.swift b/Mobile Music Assistant/ServicesMAWebSocketClient.swift index cb77d4d..ac4c64d 100644 --- a/Mobile Music Assistant/ServicesMAWebSocketClient.swift +++ b/Mobile Music Assistant/ServicesMAWebSocketClient.swift @@ -55,6 +55,7 @@ final class MAWebSocketClient { // MARK: - Properties private(set) var connectionState: ConnectionState = .disconnected + var isConnected: Bool { connectionState == .connected } private var webSocketTask: URLSessionWebSocketTask? private let session: URLSession diff --git a/Mobile Music Assistant/ViewsMainTabView.swift b/Mobile Music Assistant/ViewsMainTabView.swift index 9d2c140..d90e19e 100644 --- a/Mobile Music Assistant/ViewsMainTabView.swift +++ b/Mobile Music Assistant/ViewsMainTabView.swift @@ -13,6 +13,7 @@ private let syncLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Mobi struct MainTabView: View { @Environment(MAService.self) private var service + @Environment(MAStoreManager.self) private var storeManager @Environment(\.scenePhase) private var scenePhase @State private var selectedTab: String = "library" @@ -40,6 +41,7 @@ struct MainTabView: View { Tab("Settings", systemImage: "gear", value: "settings") { SettingsView() } + .badge(storeManager.hasEverSupported ? Text("★") : nil) } .withToast() .task { @@ -597,6 +599,7 @@ struct SettingsView: View { @Environment(MAStoreManager.self) private var storeManager @State private var showThankYou = false @State private var showClearCacheConfirm = false + @AppStorage("liveActivityEnabled") private var liveActivityEnabled = true var body: some View { NavigationStack { @@ -679,6 +682,22 @@ struct SettingsView: View { Text("Connection") } + // Now Playing Section + Section { + Toggle(isOn: $liveActivityEnabled) { + Label("Lock Screen & Dynamic Island", systemImage: "music.note.list") + } + .onChange(of: liveActivityEnabled) { _, enabled in + if !enabled { + service.playerManager.liveActivityManager.end() + } + } + } header: { + Text("Now Playing") + } footer: { + Text("Shows the current track on the Lock Screen and in the Dynamic Island.") + } + // Actions Section Section { Button(role: .destructive) { @@ -698,6 +717,20 @@ struct SettingsView: View { // Support Development Section Section { + // Supporter badge row + if storeManager.hasEverSupported { + HStack(spacing: 10) { + Image(systemName: "star.fill") + .foregroundStyle(.orange) + Text("Supporter") + .font(.body.weight(.semibold)) + .foregroundStyle(.orange) + Spacer() + Text("Thank you! ♥") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } if let loadError = storeManager.loadError { Label(loadError, systemImage: "exclamationmark.triangle") .font(.caption) diff --git a/Mobile Music Assistant/ViewsRootView.swift b/Mobile Music Assistant/ViewsRootView.swift index d89002a..e08e7a0 100644 --- a/Mobile Music Assistant/ViewsRootView.swift +++ b/Mobile Music Assistant/ViewsRootView.swift @@ -10,10 +10,12 @@ import UIKit struct RootView: View { @Environment(MAService.self) private var service + @Environment(MAStoreManager.self) private var storeManager @State private var isInitializing = true @State private var loadingProgress: Double = 0.0 @State private var loadingPhase: String = "Starting…" + @State private var showNudge = false var body: some View { Group { @@ -32,9 +34,26 @@ struct RootView: View { .animation(.easeInOut(duration: 0.4), value: service.isConnected) .applyTheme() .applyLocale() + .sheet(isPresented: $showNudge, onDismiss: { + storeManager.recordNudgeShown() + }) { + SupportNudgeView(isPresented: $showNudge) + } .task { await initializeConnection() } + .onChange(of: isInitializing) { _, initializing in + if !initializing { checkNudge() } + } + } + + private func checkNudge() { + guard storeManager.shouldShowNudge else { return } + // Small delay so the main UI settles before the sheet appears + Task { + try? await Task.sleep(for: .seconds(1)) + showNudge = true + } } // MARK: - Initialization diff --git a/MobileMAShared/Package.swift b/MobileMAShared/Package.swift index 77fcf13..5561abd 100644 --- a/MobileMAShared/Package.swift +++ b/MobileMAShared/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.0 import PackageDescription let package = Package( diff --git a/MobileMusicAssistantTests/MALibraryManagerTests.swift b/MobileMusicAssistantTests/MALibraryManagerTests.swift index 76d5a0a..05b3968 100644 --- a/MobileMusicAssistantTests/MALibraryManagerTests.swift +++ b/MobileMusicAssistantTests/MALibraryManagerTests.swift @@ -82,9 +82,9 @@ struct FavoriteURICollectionTests { @Test("Collects URIs of items marked as favorite") func collectsFavoriteURIs() throws { let items: [MAMediaItem] = try [ - """{"uri":"spotify://1","name":"A","favorite":true}""", - """{"uri":"spotify://2","name":"B","favorite":false}""", - """{"uri":"spotify://3","name":"C","favorite":true}""", + #"{"uri":"spotify://1","name":"A","favorite":true}"#, + #"{"uri":"spotify://2","name":"B","favorite":false}"#, + #"{"uri":"spotify://3","name":"C","favorite":true}"#, ].map { try JSONDecoder().decode(MAMediaItem.self, from: Data($0.utf8)) } @@ -96,8 +96,8 @@ struct FavoriteURICollectionTests { @Test("Returns empty set when no items are favorite") func emptyWhenNoFavorites() throws { let items: [MAMediaItem] = try [ - """{"uri":"x://1","name":"A","favorite":false}""", - """{"uri":"x://2","name":"B"}""", + #"{"uri":"x://1","name":"A","favorite":false}"#, + #"{"uri":"x://2","name":"B"}"#, ].map { try JSONDecoder().decode(MAMediaItem.self, from: Data($0.utf8)) } @@ -108,7 +108,7 @@ struct FavoriteURICollectionTests { @Test("isFavorite check on MALibraryManager respects favoriteURIs set") func isFavoriteCheck() { - let manager = MALibraryManager() + let manager = MALibraryManager(service: nil) // Initially no favorites #expect(manager.isFavorite(uri: "spotify://track/1") == false) } @@ -121,22 +121,22 @@ struct LibraryManagerInitialStateTests { @Test("Artists collection starts empty") func artistsStartEmpty() { - #expect(MALibraryManager().artists.isEmpty) + #expect(MALibraryManager(service: nil).artists.isEmpty) } @Test("Albums collection starts empty") func albumsStartEmpty() { - #expect(MALibraryManager().albums.isEmpty) + #expect(MALibraryManager(service: nil).albums.isEmpty) } @Test("Playlists collection starts empty") func playlistsStartEmpty() { - #expect(MALibraryManager().playlists.isEmpty) + #expect(MALibraryManager(service: nil).playlists.isEmpty) } @Test("Loading flags start as false") func loadingFlagsStartFalse() { - let m = MALibraryManager() + let m = MALibraryManager(service: nil) #expect(m.isLoadingArtists == false) #expect(m.isLoadingAlbums == false) #expect(m.isLoadingPlaylists == false) @@ -144,6 +144,6 @@ struct LibraryManagerInitialStateTests { @Test("favoriteURIs starts empty") func favoriteURIsStartEmpty() { - #expect(MALibraryManager().favoriteURIs.isEmpty) + #expect(MALibraryManager(service: nil).favoriteURIs.isEmpty) } } diff --git a/MobileMusicAssistantTests/MAModelsTests.swift b/MobileMusicAssistantTests/MAModelsTests.swift index a23ab61..700196b 100644 --- a/MobileMusicAssistantTests/MAModelsTests.swift +++ b/MobileMusicAssistantTests/MAModelsTests.swift @@ -32,14 +32,14 @@ struct MAPlayerDecodingTests { #expect(player.available == true) } - @Test("Defaults powered and available when missing") + @Test("powered defaults to false when missing; available defaults to true when missing") func defaultsMissingBooleans() throws { let json = """ {"player_id": "x", "name": "Test", "state": "idle"} """ let player = try decode(json) #expect(player.powered == false) - #expect(player.available == false) + #expect(player.available == true) } @Test("Unknown state falls back to idle") diff --git a/MobileMusicAssistantTests/MAPlayerManagerTests.swift b/MobileMusicAssistantTests/MAPlayerManagerTests.swift index 2b0a27c..13d09a5 100644 --- a/MobileMusicAssistantTests/MAPlayerManagerTests.swift +++ b/MobileMusicAssistantTests/MAPlayerManagerTests.swift @@ -1,5 +1,6 @@ import Testing import Foundation +import UIKit @testable import Mobile_Music_Assistant // MARK: - Live Activity Player Selection diff --git a/MobileMusicAssistantTests/MAStoreManagerTests.swift b/MobileMusicAssistantTests/MAStoreManagerTests.swift index 27aba7a..2cdc236 100644 --- a/MobileMusicAssistantTests/MAStoreManagerTests.swift +++ b/MobileMusicAssistantTests/MAStoreManagerTests.swift @@ -5,6 +5,7 @@ import Foundation // MARK: - MAStoreManager Nudge Logic @Suite("MAStoreManager – Nudge Logic") +@MainActor struct MAStoreManagerNudgeTests { // Use isolated UserDefaults to avoid polluting real app state diff --git a/MobileMusicAssistantTests/MAWebSocketClientTests.swift b/MobileMusicAssistantTests/MAWebSocketClientTests.swift index c432d68..0d96e3d 100644 --- a/MobileMusicAssistantTests/MAWebSocketClientTests.swift +++ b/MobileMusicAssistantTests/MAWebSocketClientTests.swift @@ -148,9 +148,9 @@ struct AnyCodableTests { @Test("Dictionary decodes keys correctly") func dictRoundTrip() throws { - let dict = AnyCodable(["key": AnyCodable("value")]) - let encoded = try JSONEncoder().encode(dict) - let decoded = try JSONDecoder().decode(AnyCodable.self, from: encoded) + // Build the AnyCodable from raw JSON to avoid double-wrapping issues + let json = #"{"key":"value"}"# + let decoded = try JSONDecoder().decode(AnyCodable.self, from: Data(json.utf8)) let map = try decoded.decode(as: [String: AnyCodable].self) #expect(try map["key"]?.decode(as: String.self) == "value") } diff --git a/ServicesMAStoreManager.swift b/ServicesMAStoreManager.swift index 367b336..de0855b 100644 --- a/ServicesMAStoreManager.swift +++ b/ServicesMAStoreManager.swift @@ -29,12 +29,60 @@ enum PurchaseResult: Equatable { "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 { @@ -60,6 +108,7 @@ enum PurchaseResult: Equatable { case .success(let verification): let transaction = try checkVerified(verification) await transaction.finish() + recordPurchase() purchaseResult = .success(product) case .userCancelled: purchaseResult = .cancelled