Live Activities, nudging, unit tests

This commit is contained in:
2026-04-20 11:11:24 +02:00
parent 3858500a45
commit 7e25a4f978
15 changed files with 348 additions and 20 deletions
+2 -2
View File
@@ -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",
@@ -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 = "<group>";
};
470E2652A47C438395161BBE /* MobileMusicAssistantTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = MobileMusicAssistantTests;
sourceTree = "<group>";
};
/* 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 = "<group>";
};
26DA6F7D2F928B2100849EC7 /* Frameworks */ = {
isa = PBXGroup;
children = (
@@ -120,6 +154,8 @@
26A83F522F825B3C003834A9 /* ViewsPlayerNowPlayingView.swift */,
2681ED6E2F8393AC002FB204 /* ViewsPlayerQueueView.swift */,
2616AF4D2F876BEF00CB210E /* ServicesMAStoreManager.swift */,
470E2652A47C438395161BBE /* MobileMusicAssistantTests */,
26D648CD2F961C3D0095ADFE /* Recovered References */,
);
sourceTree = "<group>";
};
@@ -128,6 +164,7 @@
children = (
26ED92612F759EEA0025419D /* Mobile Music Assistant.app */,
26DA6F7C2F928B2100849EC7 /* MobileMALiveActivityExtension.appex */,
B8A2C9310F1D4E7B8C456DEF /* MobileMusicAssistantTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -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 = (
@@ -21,6 +21,20 @@
ReferencedContainer = "container:Mobile Music Assistant.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "238344CE1A9D4B65ADFC4985"
BuildableName = "MobileMusicAssistantTests.xctest"
BlueprintName = "MobileMusicAssistantTests"
ReferencedContainer = "container:Mobile Music Assistant.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
@@ -29,6 +43,19 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "238344CE1A9D4B65ADFC4985"
BuildableName = "MobileMusicAssistantTests.xctest"
BlueprintName = "MobileMusicAssistantTests"
ReferencedContainer = "container:Mobile Music Assistant.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
+66 -1
View File
@@ -187,6 +187,10 @@
"comment" : "A separator between the year and the number of tracks in an album.",
"isCommentAutoGenerated" : true
},
"★" : {
"comment" : "A star emoji.",
"isCommentAutoGenerated" : true
},
"1. Open Music Assistant in a browser" : {
"localizations" : {
"de" : {
@@ -1414,6 +1418,10 @@
}
}
},
"Keep Mobile MA Growing" : {
"comment" : "A title of a view that asks the user to support development.",
"isCommentAutoGenerated" : true
},
"Language" : {
"localizations" : {
"de" : {
@@ -1502,6 +1510,28 @@
}
}
},
"Lock Screen & Dynamic Island" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sperrbildschirm & Dynamic Island"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pantalla de bloqueo & Dynamic Island"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Écran verrouillé & Dynamic Island"
}
}
}
},
"Long-Lived Access Token" : {
"localizations" : {
"de" : {
@@ -1546,10 +1576,18 @@
}
}
},
"Maybe Later" : {
"comment" : "A button that dismisses the support nudge.",
"isCommentAutoGenerated" : true
},
"Mobile MA" : {
"comment" : "The name of the app.",
"isCommentAutoGenerated" : true
},
"Mobile MA is a free, passion-driven app. If it brings music to your life, a small donation helps keep it alive and growing." : {
"comment" : "A description of the benefits of supporting the development of Mobile MA.",
"isCommentAutoGenerated" : true
},
"Music Assistant" : {
"extractionState" : "stale",
"localizations" : {
@@ -1986,7 +2024,6 @@
"isCommentAutoGenerated" : true
},
"Now Playing" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2563,6 +2600,28 @@
}
}
},
"Shows the current track on the Lock Screen and in the Dynamic Island." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zeigt den aktuellen Titel auf dem Sperrbildschirm und in der Dynamic Island an."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Muestra la pista actual en la pantalla de bloqueo y en la Dynamic Island."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Affiche la piste en cours sur l'écran verrouillé et dans la Dynamic Island."
}
}
}
},
"Shuffle" : {
"localizations" : {
"de" : {
@@ -2654,6 +2713,9 @@
}
}
}
},
"Supporter" : {
},
"Synced to: %@" : {
"localizations" : {
@@ -2812,6 +2874,9 @@
}
}
}
},
"Thank you! ♥" : {
},
"This will delete all locally cached artwork and library data. The next launch or reload may take longer while everything is fetched again." : {
"localizations" : {
@@ -149,6 +149,11 @@ final class MAPlayerManager {
/// Finds the best currently-playing player and pushes its state to the Live Activity.
/// Spawns a Task to fetch artwork with auth before updating.
private func updateLiveActivity() {
guard UserDefaults.standard.object(forKey: "liveActivityEnabled") as? Bool ?? true else {
liveActivityManager.end()
return
}
let playing = players.values
.filter { $0.state == .playing }
.first { $0.currentItem != nil || playerQueues[$0.playerId]?.currentItem != nil }
@@ -248,6 +253,9 @@ final class MAPlayerManager {
}
}
/// Exposed for unit testing only.
static func testResizeAndEncode(_ image: UIImage) -> 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 400700 bytes, well within limits.
@@ -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
@@ -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)
@@ -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
+1 -1
View File
@@ -1,4 +1,4 @@
// swift-tools-version: 5.9
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
@@ -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)
}
}
@@ -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")
@@ -1,5 +1,6 @@
import Testing
import Foundation
import UIKit
@testable import Mobile_Music_Assistant
// MARK: - Live Activity Player Selection
@@ -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
@@ -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")
}
+49
View File
@@ -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<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 {
@@ -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