Live Activities, nudging, unit tests
This commit is contained in:
+2
-2
@@ -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 = (
|
||||
|
||||
+27
@@ -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"
|
||||
|
||||
@@ -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 ≈ 400–700 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,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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user