summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Docs/StoreKit.md42
-rw-r--r--Hutch.xcodeproj/project.pbxproj18
-rw-r--r--Hutch.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings5
-rw-r--r--Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme3
-rw-r--r--Hutch/Views/More/AboutView.swift71
-rw-r--r--Hutch/Views/More/TipStoreViewModel.swift118
-rw-r--r--HutchTests/TipStoreViewModelTests.swift22
-rw-r--r--SyncedProducts.storekit87
8 files changed, 338 insertions, 28 deletions
diff --git a/Docs/StoreKit.md b/Docs/StoreKit.md
new file mode 100644
index 0000000..0d2eb33
--- /dev/null
+++ b/Docs/StoreKit.md
@@ -0,0 +1,42 @@
+# StoreKit Setup For Hutch
+
+Hutch uses StoreKit 2 for one-time consumable tip products:
+
+- `net.cleberg.hutch.tip.small`
+- `net.cleberg.hutch.tip.medium`
+- `net.cleberg.hutch.tip.large`
+
+## Xcode setup
+
+1. Open `Hutch.xcodeproj` in Xcode.
+2. Select the `Hutch` target.
+3. Open `Signing & Capabilities`.
+4. Confirm the App ID for `net.cleberg.Hutch` has the In-App Purchase capability enabled in the Apple Developer portal.
+5. In Xcode, choose `File > New > File... > StoreKit Configuration File`.
+6. Prefer a synced StoreKit file so Xcode pulls the real products from App Store Connect.
+7. Open `Product > Scheme > Edit Scheme...`.
+8. Select `Run`, then the `Options` tab.
+9. Set `StoreKit Configuration` to the synced `.storekit` file for local testing.
+
+## App Store Connect checks
+
+1. Confirm all three products exist with the exact product IDs listed above.
+2. Confirm each product has complete metadata, pricing, and availability.
+3. If a product is in `Developer Action Needed`, edit the rejected detail or cancel the pending change, then resubmit it.
+4. Wait up to one hour for metadata changes to propagate to sandbox.
+
+## Sandbox test checklist
+
+1. On a physical device, enable Developer Mode if needed.
+2. Sign in to a Sandbox Apple Account in `Settings > Developer > Sandbox Apple Account`.
+3. Install a development build from Xcode or a TestFlight build.
+4. Open `More > About`.
+5. Verify the tip products load with names and prices.
+6. Complete one purchase successfully.
+7. Use `Restore / Sync Purchases` to verify StoreKit can talk to the App Store account.
+8. Record this full flow for App Review when resubmitting.
+
+## Code locations
+
+- `Hutch/Views/More/TipStoreViewModel.swift`
+- `Hutch/Views/More/AboutView.swift`
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 8d8dc45..eca3935 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -54,6 +54,7 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
+ 8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; };
8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
8B2F89642F69DEB900FC0253 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
8B2F89652F69DEB900FC0253 /* SECURITY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = SECURITY.md; sourceTree = "<group>"; };
@@ -169,6 +170,7 @@
8B4B28C82F6704280045FA19 = {
isa = PBXGroup;
children = (
+ 8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */,
8B2FA9CC2F7E211800E609E2 /* Docs */,
8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */,
8B8182C22F6B742B000AE049 /* HutchTests */,
@@ -515,7 +517,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 77;
+ CURRENT_PROJECT_VERSION = 78;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +534,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.1.9;
+ MARKETING_VERSION = 3.1.10;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +554,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 77;
+ CURRENT_PROJECT_VERSION = 78;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +571,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.1.9;
+ MARKETING_VERSION = 3.1.10;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +634,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 77;
+ CURRENT_PROJECT_VERSION = 78;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +644,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.1.9;
+ MARKETING_VERSION = 3.1.10;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -661,7 +663,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 77;
+ CURRENT_PROJECT_VERSION = 78;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +673,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.1.9;
+ MARKETING_VERSION = 3.1.10;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/Hutch.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Hutch.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..0c67376
--- /dev/null
+++ b/Hutch.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict/>
+</plist>
diff --git a/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme
index d05e796..974a7f8 100644
--- a/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme
+++ b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme
@@ -68,6 +68,9 @@
ReferencedContainer = "container:Hutch.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
+ <StoreKitConfigurationFileReference
+ identifier = "../../SyncedProducts.storekit">
+ </StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
diff --git a/Hutch/Views/More/AboutView.swift b/Hutch/Views/More/AboutView.swift
index 887bef1..106c8d2 100644
--- a/Hutch/Views/More/AboutView.swift
+++ b/Hutch/Views/More/AboutView.swift
@@ -93,12 +93,18 @@ struct AboutView: View {
Section {
if storeViewModel.isLoading {
- ProgressView()
+ ProgressView("Loading tip options…")
.themedRow()
} else if storeViewModel.products.isEmpty {
- Text("Tips unavailable")
- .foregroundStyle(.secondary)
- .themedRow()
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Tips unavailable")
+ .font(.headline)
+ Text(storeViewModel.errorMessage ?? "Hutch couldn't load tip products from the App Store yet.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 4)
+ .themedRow()
} else {
ForEach(storeViewModel.products, id: \.id) { product in
Button {
@@ -107,15 +113,49 @@ struct AboutView: View {
}
} label: {
HStack {
- Text(product.displayName)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(product.displayName)
+ Text(product.id)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
Spacer()
- Text(product.displayPrice)
- .foregroundStyle(.secondary)
+ if storeViewModel.isPurchasing(product) {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text(product.displayPrice)
+ .foregroundStyle(.secondary)
+ }
}
}
+ .disabled(storeViewModel.purchasingProductID != nil || storeViewModel.isRestoringPurchases)
+ .themedRow()
+ }
+ }
+
+ if let errorMessage = storeViewModel.errorMessage {
+ Text(errorMessage)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
.themedRow()
+ }
+
+ Button("Retry Loading Tips") {
+ Task {
+ await storeViewModel.loadProducts()
+ }
+ }
+ .disabled(storeViewModel.isLoading || storeViewModel.purchasingProductID != nil)
+ .themedRow()
+
+ Button(storeViewModel.isRestoringPurchases ? "Syncing Purchases…" : "Restore / Sync Purchases") {
+ Task {
+ await storeViewModel.restorePurchases()
}
}
+ .disabled(storeViewModel.isLoading || storeViewModel.purchasingProductID != nil || storeViewModel.isRestoringPurchases)
+ .themedRow()
} header: {
Text("Support Development")
} footer: {
@@ -139,6 +179,23 @@ struct AboutView: View {
.themedList()
.navigationTitle("About")
.navigationBarTitleDisplayMode(.inline)
+ .alert(
+ "Store Message",
+ isPresented: Binding(
+ get: { storeViewModel.statusMessage != nil },
+ set: { isPresented in
+ if !isPresented {
+ storeViewModel.clearStatusMessage()
+ }
+ }
+ )
+ ) {
+ Button("OK") {
+ storeViewModel.clearStatusMessage()
+ }
+ } message: {
+ Text(storeViewModel.statusMessage ?? "")
+ }
.task {
await storeViewModel.loadProducts()
}
diff --git a/Hutch/Views/More/TipStoreViewModel.swift b/Hutch/Views/More/TipStoreViewModel.swift
index 54868ba..bc909c1 100644
--- a/Hutch/Views/More/TipStoreViewModel.swift
+++ b/Hutch/Views/More/TipStoreViewModel.swift
@@ -2,43 +2,135 @@ import StoreKit
@Observable
final class TipStoreViewModel {
+ enum TipProduct: String, CaseIterable {
+ case small
+ case medium
+ case large
+
+ var id: String {
+ "net.cleberg.hutch.tip.\(rawValue)"
+ }
+
+ var displayName: String {
+ switch self {
+ case .small:
+ "Small Tip"
+ case .medium:
+ "Medium Tip"
+ case .large:
+ "Large Tip"
+ }
+ }
+ }
+
+ static let productIDs = TipProduct.allCases.map(\.id)
+
var products: [Product] = []
var isLoading = false
+ var isRestoringPurchases = false
+ var purchasingProductID: String?
var errorMessage: String?
+ var statusMessage: String?
+
+ private var transactionUpdatesTask: Task<Void, Never>?
- private let productIDs = [
- "net.cleberg.hutch.tip.small",
- "net.cleberg.hutch.tip.medium",
- "net.cleberg.hutch.tip.large"
- ]
+ init() {
+ transactionUpdatesTask = Task.detached(priority: .background) {
+ for await verification in Transaction.updates {
+ guard case .verified(let transaction) = verification else { continue }
+ await transaction.finish()
+ }
+ }
+ }
+
+ deinit {
+ transactionUpdatesTask?.cancel()
+ }
+ @MainActor
func loadProducts() async {
+ guard !isLoading else { return }
+
isLoading = true
+ errorMessage = nil
defer { isLoading = false }
+
do {
- let fetched = try await Product.products(for: productIDs)
- // Sort by price ascending to maintain small/medium/large order
- products = fetched.sorted { $0.price < $1.price }
+ let fetched = try await Product.products(for: Self.productIDs)
+ let productsByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
+ let orderedProducts = TipProduct.allCases.compactMap { productsByID[$0.id] }
+ let missingProducts = TipProduct.allCases.filter { productsByID[$0.id] == nil }
+
+ products = orderedProducts
+
+ if !missingProducts.isEmpty {
+ let missingNames = missingProducts.map(\.displayName).joined(separator: ", ")
+ errorMessage = "Missing products from the App Store response: \(missingNames). Confirm the product identifiers match App Store Connect exactly and that each item is approved or available in sandbox."
+ }
} catch {
- errorMessage = error.localizedDescription
+ products = []
+ errorMessage = "Couldn't load tips from the App Store. \(error.localizedDescription)"
}
}
+ @MainActor
func purchase(_ product: Product) async {
+ guard purchasingProductID == nil else { return }
+
+ purchasingProductID = product.id
+ errorMessage = nil
+ statusMessage = nil
+ defer { purchasingProductID = nil }
+
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
- if case .verified(let transaction) = verification {
+ switch verification {
+ case .verified(let transaction):
await transaction.finish()
+ statusMessage = "Purchase completed successfully."
+ case .unverified(_, let error):
+ errorMessage = "The App Store returned an unverified transaction. \(error.localizedDescription)"
}
- case .userCancelled, .pending:
+
+ case .pending:
+ statusMessage = "Purchase is pending approval."
+
+ case .userCancelled:
break
+
@unknown default:
- break
+ errorMessage = "The App Store returned an unknown purchase result."
}
} catch {
- errorMessage = error.localizedDescription
+ errorMessage = "Purchase failed. \(error.localizedDescription)"
+ }
+ }
+
+ @MainActor
+ func restorePurchases() async {
+ guard !isRestoringPurchases else { return }
+
+ isRestoringPurchases = true
+ errorMessage = nil
+ defer { isRestoringPurchases = false }
+
+ do {
+ try await AppStore.sync()
+ statusMessage = "Purchase history synced with the App Store."
+ await loadProducts()
+ } catch {
+ errorMessage = "Couldn't sync purchases. \(error.localizedDescription)"
}
}
+
+ @MainActor
+ func clearStatusMessage() {
+ statusMessage = nil
+ }
+
+ func isPurchasing(_ product: Product) -> Bool {
+ purchasingProductID == product.id
+ }
}
diff --git a/HutchTests/TipStoreViewModelTests.swift b/HutchTests/TipStoreViewModelTests.swift
new file mode 100644
index 0000000..ed58d1e
--- /dev/null
+++ b/HutchTests/TipStoreViewModelTests.swift
@@ -0,0 +1,22 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct TipStoreViewModelTests {
+
+ @Test
+ func tipProductIdentifiersMatchAppStoreConnectOrder() {
+ #expect(TipStoreViewModel.productIDs == [
+ "net.cleberg.hutch.tip.small",
+ "net.cleberg.hutch.tip.medium",
+ "net.cleberg.hutch.tip.large",
+ ])
+ }
+
+ @Test
+ func tipProductDisplayNamesMatchReviewMetadata() {
+ #expect(TipStoreViewModel.TipProduct.small.displayName == "Small Tip")
+ #expect(TipStoreViewModel.TipProduct.medium.displayName == "Medium Tip")
+ #expect(TipStoreViewModel.TipProduct.large.displayName == "Large Tip")
+ }
+}
diff --git a/SyncedProducts.storekit b/SyncedProducts.storekit
new file mode 100644
index 0000000..6d8ae4c
--- /dev/null
+++ b/SyncedProducts.storekit
@@ -0,0 +1,87 @@
+{
+ "appPolicies" : {
+ "eula" : "",
+ "policies" : [
+ {
+ "locale" : "en_US",
+ "policyText" : "",
+ "policyURL" : ""
+ }
+ ]
+ },
+ "identifier" : "8055EC65",
+ "nonRenewingSubscriptions" : [
+
+ ],
+ "products" : [
+ {
+ "displayPrice" : "4.99",
+ "familyShareable" : false,
+ "internalID" : "6762209132",
+ "localizations" : [
+ {
+ "description" : "Large Tip",
+ "displayName" : "Large Tip",
+ "locale" : "en_US"
+ }
+ ],
+ "productID" : "net.cleberg.hutch.tip.large",
+ "referenceName" : "Large Tip",
+ "type" : "Consumable"
+ },
+ {
+ "displayPrice" : "2.99",
+ "familyShareable" : false,
+ "internalID" : "6762209073",
+ "localizations" : [
+ {
+ "description" : "Medium Tip",
+ "displayName" : "Medium Tip",
+ "locale" : "en_US"
+ }
+ ],
+ "productID" : "net.cleberg.hutch.tip.medium",
+ "referenceName" : "Medium Tip",
+ "type" : "Consumable"
+ },
+ {
+ "displayPrice" : "0.99",
+ "familyShareable" : false,
+ "internalID" : "6762209018",
+ "localizations" : [
+ {
+ "description" : "Small Tip",
+ "displayName" : "Small Tip",
+ "locale" : "en_US"
+ }
+ ],
+ "productID" : "net.cleberg.hutch.tip.small",
+ "referenceName" : "Small Tip",
+ "type" : "Consumable"
+ }
+ ],
+ "settings" : {
+ "_applicationInternalID" : "6760742299",
+ "_askToBuyEnabled" : false,
+ "_billingGracePeriodEnabled" : false,
+ "_billingIssuesEnabled" : false,
+ "_developerTeamID" : "ZCNAX3VL9D",
+ "_disableDialogs" : false,
+ "_failTransactionsEnabled" : false,
+ "_lastSynchronizedDate" : 798317262.14421904,
+ "_locale" : "en_US",
+ "_renewalBillingIssuesEnabled" : false,
+ "_storefront" : "USA",
+ "_storeKitErrors" : [
+
+ ],
+ "_timeRate" : 0
+ },
+ "subscriptionGroups" : [
+
+ ],
+ "version" : {
+ "major" : 5,
+ "minor" : 0
+ }
+}