Commit de690661f6
Unsigned
Layout: unified · split
Docs/StoreKit.md added +42
| @@ -0,0 +1,42 @@ | |||
| 1 | # StoreKit Setup For Hutch | ||
| 2 | |||
| 3 | Hutch uses StoreKit 2 for one-time consumable tip products: | ||
| 4 | |||
| 5 | - `net.cleberg.hutch.tip.small` | ||
| 6 | - `net.cleberg.hutch.tip.medium` | ||
| 7 | - `net.cleberg.hutch.tip.large` | ||
| 8 | |||
| 9 | ## Xcode setup | ||
| 10 | |||
| 11 | 1. Open `Hutch.xcodeproj` in Xcode. | ||
| 12 | 2. Select the `Hutch` target. | ||
| 13 | 3. Open `Signing & Capabilities`. | ||
| 14 | 4. Confirm the App ID for `net.cleberg.Hutch` has the In-App Purchase capability enabled in the Apple Developer portal. | ||
| 15 | 5. In Xcode, choose `File > New > File... > StoreKit Configuration File`. | ||
| 16 | 6. Prefer a synced StoreKit file so Xcode pulls the real products from App Store Connect. | ||
| 17 | 7. Open `Product > Scheme > Edit Scheme...`. | ||
| 18 | 8. Select `Run`, then the `Options` tab. | ||
| 19 | 9. Set `StoreKit Configuration` to the synced `.storekit` file for local testing. | ||
| 20 | |||
| 21 | ## App Store Connect checks | ||
| 22 | |||
| 23 | 1. Confirm all three products exist with the exact product IDs listed above. | ||
| 24 | 2. Confirm each product has complete metadata, pricing, and availability. | ||
| 25 | 3. If a product is in `Developer Action Needed`, edit the rejected detail or cancel the pending change, then resubmit it. | ||
| 26 | 4. Wait up to one hour for metadata changes to propagate to sandbox. | ||
| 27 | |||
| 28 | ## Sandbox test checklist | ||
| 29 | |||
| 30 | 1. On a physical device, enable Developer Mode if needed. | ||
| 31 | 2. Sign in to a Sandbox Apple Account in `Settings > Developer > Sandbox Apple Account`. | ||
| 32 | 3. Install a development build from Xcode or a TestFlight build. | ||
| 33 | 4. Open `More > About`. | ||
| 34 | 5. Verify the tip products load with names and prices. | ||
| 35 | 6. Complete one purchase successfully. | ||
| 36 | 7. Use `Restore / Sync Purchases` to verify StoreKit can talk to the App Store account. | ||
| 37 | 8. Record this full flow for App Review when resubmitting. | ||
| 38 | |||
| 39 | ## Code locations | ||
| 40 | |||
| 41 | - `Hutch/Views/More/TipStoreViewModel.swift` | ||
| 42 | - `Hutch/Views/More/AboutView.swift` | ||
Hutch.xcodeproj/project.pbxproj +10 −8
| @@ -54,6 +54,7 @@ | |||
| 54 | /* End PBXCopyFilesBuildPhase section */ | 54 | /* End PBXCopyFilesBuildPhase section */ |
| 55 | 55 | ||
| 56 | /* Begin PBXFileReference section */ | 56 | /* Begin PBXFileReference section */ |
| 57 | 8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; }; | ||
| 57 | 8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; }; | 58 | 8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; }; |
| 58 | 8B2F89642F69DEB900FC0253 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; }; | 59 | 8B2F89642F69DEB900FC0253 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; }; |
| 59 | 8B2F89652F69DEB900FC0253 /* SECURITY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = SECURITY.md; sourceTree = "<group>"; }; | 60 | 8B2F89652F69DEB900FC0253 /* SECURITY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = SECURITY.md; sourceTree = "<group>"; }; |
| @@ -169,6 +170,7 @@ | |||
| 169 | 8B4B28C82F6704280045FA19 = { | 170 | 8B4B28C82F6704280045FA19 = { |
| 170 | isa = PBXGroup; | 171 | isa = PBXGroup; |
| 171 | children = ( | 172 | children = ( |
| 173 | 8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */, | ||
| 172 | 8B2FA9CC2F7E211800E609E2 /* Docs */, | 174 | 8B2FA9CC2F7E211800E609E2 /* Docs */, |
| 173 | 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */, | 175 | 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */, |
| 174 | 8B8182C22F6B742B000AE049 /* HutchTests */, | 176 | 8B8182C22F6B742B000AE049 /* HutchTests */, |
| @@ -515,7 +517,7 @@ | |||
| 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 517 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 518 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | CODE_SIGN_STYLE = Automatic; | 519 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 77; | 520 | CURRENT_PROJECT_VERSION = 78; |
| 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 521 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | ENABLE_PREVIEWS = YES; | 522 | ENABLE_PREVIEWS = YES; |
| 521 | GENERATE_INFOPLIST_FILE = YES; | 523 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +534,7 @@ | |||
| 532 | "$(inherited)", | 534 | "$(inherited)", |
| 533 | "@executable_path/Frameworks", | 535 | "@executable_path/Frameworks", |
| 534 | ); | 536 | ); |
| 535 | MARKETING_VERSION = 3.1.9; | 537 | MARKETING_VERSION = 3.1.10; |
| 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 538 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | PRODUCT_NAME = "$(TARGET_NAME)"; | 539 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 540 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +554,7 @@ | |||
| 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 554 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 555 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | CODE_SIGN_STYLE = Automatic; | 556 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 77; | 557 | CURRENT_PROJECT_VERSION = 78; |
| 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 558 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | ENABLE_PREVIEWS = YES; | 559 | ENABLE_PREVIEWS = YES; |
| 558 | GENERATE_INFOPLIST_FILE = YES; | 560 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +571,7 @@ | |||
| 569 | "$(inherited)", | 571 | "$(inherited)", |
| 570 | "@executable_path/Frameworks", | 572 | "@executable_path/Frameworks", |
| 571 | ); | 573 | ); |
| 572 | MARKETING_VERSION = 3.1.9; | 574 | MARKETING_VERSION = 3.1.10; |
| 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 575 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | PRODUCT_NAME = "$(TARGET_NAME)"; | 576 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 577 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +634,7 @@ | |||
| 632 | APPLICATION_EXTENSION_API_ONLY = YES; | 634 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 635 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | CODE_SIGN_STYLE = Automatic; | 636 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 77; | 637 | CURRENT_PROJECT_VERSION = 78; |
| 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 638 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | GENERATE_INFOPLIST_FILE = NO; | 639 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 640 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +644,7 @@ | |||
| 642 | "@executable_path/Frameworks", | 644 | "@executable_path/Frameworks", |
| 643 | "@executable_path/../../Frameworks", | 645 | "@executable_path/../../Frameworks", |
| 644 | ); | 646 | ); |
| 645 | MARKETING_VERSION = 3.1.9; | 647 | MARKETING_VERSION = 3.1.10; |
| 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 648 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | PRODUCT_NAME = "$(TARGET_NAME)"; | 649 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | SKIP_INSTALL = YES; | 650 | SKIP_INSTALL = YES; |
| @@ -661,7 +663,7 @@ | |||
| 661 | APPLICATION_EXTENSION_API_ONLY = YES; | 663 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 664 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | CODE_SIGN_STYLE = Automatic; | 665 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 77; | 666 | CURRENT_PROJECT_VERSION = 78; |
| 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 667 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | GENERATE_INFOPLIST_FILE = NO; | 668 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 669 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +673,7 @@ | |||
| 671 | "@executable_path/Frameworks", | 673 | "@executable_path/Frameworks", |
| 672 | "@executable_path/../../Frameworks", | 674 | "@executable_path/../../Frameworks", |
| 673 | ); | 675 | ); |
| 674 | MARKETING_VERSION = 3.1.9; | 676 | MARKETING_VERSION = 3.1.10; |
| 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 677 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | PRODUCT_NAME = "$(TARGET_NAME)"; | 678 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | SKIP_INSTALL = YES; | 679 | SKIP_INSTALL = YES; |
Hutch.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings added +5
| @@ -0,0 +1,5 @@ | |||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| 3 | <plist version="1.0"> | ||
| 4 | <dict/> | ||
| 5 | </plist> | ||
Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme +3
| @@ -68,6 +68,9 @@ | |||
| 68 | ReferencedContainer = "container:Hutch.xcodeproj"> | 68 | ReferencedContainer = "container:Hutch.xcodeproj"> |
| 69 | </BuildableReference> | 69 | </BuildableReference> |
| 70 | </BuildableProductRunnable> | 70 | </BuildableProductRunnable> |
| 71 | <StoreKitConfigurationFileReference | ||
| 72 | identifier = "../../SyncedProducts.storekit"> | ||
| 73 | </StoreKitConfigurationFileReference> | ||
| 71 | </LaunchAction> | 74 | </LaunchAction> |
| 72 | <ProfileAction | 75 | <ProfileAction |
| 73 | buildConfiguration = "Release" | 76 | buildConfiguration = "Release" |
Hutch/Views/More/AboutView.swift +64 −7
| @@ -93,12 +93,18 @@ struct AboutView: View { | |||
| 93 | 93 | ||
| 94 | Section { | 94 | Section { |
| 95 | if storeViewModel.isLoading { | 95 | if storeViewModel.isLoading { |
| 96 | ProgressView() | 96 | ProgressView("Loading tip options…") |
| 97 | .themedRow() | 97 | .themedRow() |
| 98 | } else if storeViewModel.products.isEmpty { | 98 | } else if storeViewModel.products.isEmpty { |
| 99 | Text("Tips unavailable") | 99 | VStack(alignment: .leading, spacing: 8) { |
| 100 | .foregroundStyle(.secondary) | 100 | Text("Tips unavailable") |
| 101 | .themedRow() | 101 | .font(.headline) |
| 102 | Text(storeViewModel.errorMessage ?? "Hutch couldn't load tip products from the App Store yet.") | ||
| 103 | .font(.subheadline) | ||
| 104 | .foregroundStyle(.secondary) | ||
| 105 | } | ||
| 106 | .padding(.vertical, 4) | ||
| 107 | .themedRow() | ||
| 102 | } else { | 108 | } else { |
| 103 | ForEach(storeViewModel.products, id: \.id) { product in | 109 | ForEach(storeViewModel.products, id: \.id) { product in |
| 104 | Button { | 110 | Button { |
| @@ -107,15 +113,49 @@ struct AboutView: View { | |||
| 107 | } | 113 | } |
| 108 | } label: { | 114 | } label: { |
| 109 | HStack { | 115 | HStack { |
| 110 | Text(product.displayName) | 116 | VStack(alignment: .leading, spacing: 2) { |
| 117 | Text(product.displayName) | ||
| 118 | Text(product.id) | ||
| 119 | .font(.caption) | ||
| 120 | .foregroundStyle(.secondary) | ||
| 121 | } | ||
| 111 | Spacer() | 122 | Spacer() |
| 112 | Text(product.displayPrice) | 123 | if storeViewModel.isPurchasing(product) { |
| 113 | .foregroundStyle(.secondary) | 124 | ProgressView() |
| 125 | .controlSize(.small) | ||
| 126 | } else { | ||
| 127 | Text(product.displayPrice) | ||
| 128 | .foregroundStyle(.secondary) | ||
| 129 | } | ||
| 114 | } | 130 | } |
| 115 | } | 131 | } |
| 132 | .disabled(storeViewModel.purchasingProductID != nil || storeViewModel.isRestoringPurchases) | ||
| 133 | .themedRow() | ||
| 134 | } | ||
| 135 | } | ||
| 136 | |||
| 137 | if let errorMessage = storeViewModel.errorMessage { | ||
| 138 | Text(errorMessage) | ||
| 139 | .font(.footnote) | ||
| 140 | .foregroundStyle(.secondary) | ||
| 116 | .themedRow() | 141 | .themedRow() |
| 142 | } | ||
| 143 | |||
| 144 | Button("Retry Loading Tips") { | ||
| 145 | Task { | ||
| 146 | await storeViewModel.loadProducts() | ||
| 147 | } | ||
| 148 | } | ||
| 149 | .disabled(storeViewModel.isLoading || storeViewModel.purchasingProductID != nil) | ||
| 150 | .themedRow() | ||
| 151 | |||
| 152 | Button(storeViewModel.isRestoringPurchases ? "Syncing Purchases…" : "Restore / Sync Purchases") { | ||
| 153 | Task { | ||
| 154 | await storeViewModel.restorePurchases() | ||
| 117 | } | 155 | } |
| 118 | } | 156 | } |
| 157 | .disabled(storeViewModel.isLoading || storeViewModel.purchasingProductID != nil || storeViewModel.isRestoringPurchases) | ||
| 158 | .themedRow() | ||
| 119 | } header: { | 159 | } header: { |
| 120 | Text("Support Development") | 160 | Text("Support Development") |
| 121 | } footer: { | 161 | } footer: { |
| @@ -139,6 +179,23 @@ struct AboutView: View { | |||
| 139 | .themedList() | 179 | .themedList() |
| 140 | .navigationTitle("About") | 180 | .navigationTitle("About") |
| 141 | .navigationBarTitleDisplayMode(.inline) | 181 | .navigationBarTitleDisplayMode(.inline) |
| 182 | .alert( | ||
| 183 | "Store Message", | ||
| 184 | isPresented: Binding( | ||
| 185 | get: { storeViewModel.statusMessage != nil }, | ||
| 186 | set: { isPresented in | ||
| 187 | if !isPresented { | ||
| 188 | storeViewModel.clearStatusMessage() | ||
| 189 | } | ||
| 190 | } | ||
| 191 | ) | ||
| 192 | ) { | ||
| 193 | Button("OK") { | ||
| 194 | storeViewModel.clearStatusMessage() | ||
| 195 | } | ||
| 196 | } message: { | ||
| 197 | Text(storeViewModel.statusMessage ?? "") | ||
| 198 | } | ||
| 142 | .task { | 199 | .task { |
| 143 | await storeViewModel.loadProducts() | 200 | await storeViewModel.loadProducts() |
| 144 | } | 201 | } |
Hutch/Views/More/TipStoreViewModel.swift +105 −13
| @@ -2,43 +2,135 @@ import StoreKit | |||
| 2 | 2 | ||
| 3 | @Observable | 3 | @Observable |
| 4 | final class TipStoreViewModel { | 4 | final class TipStoreViewModel { |
| 5 | enum TipProduct: String, CaseIterable { | ||
| 6 | case small | ||
| 7 | case medium | ||
| 8 | case large | ||
| 9 | |||
| 10 | var id: String { | ||
| 11 | "net.cleberg.hutch.tip.\(rawValue)" | ||
| 12 | } | ||
| 13 | |||
| 14 | var displayName: String { | ||
| 15 | switch self { | ||
| 16 | case .small: | ||
| 17 | "Small Tip" | ||
| 18 | case .medium: | ||
| 19 | "Medium Tip" | ||
| 20 | case .large: | ||
| 21 | "Large Tip" | ||
| 22 | } | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | static let productIDs = TipProduct.allCases.map(\.id) | ||
| 27 | |||
| 5 | var products: [Product] = [] | 28 | var products: [Product] = [] |
| 6 | var isLoading = false | 29 | var isLoading = false |
| 30 | var isRestoringPurchases = false | ||
| 31 | var purchasingProductID: String? | ||
| 7 | var errorMessage: String? | 32 | var errorMessage: String? |
| 33 | var statusMessage: String? | ||
| 34 | |||
| 35 | private var transactionUpdatesTask: Task<Void, Never>? | ||
| 8 | 36 | ||
| 9 | private let productIDs = [ | 37 | init() { |
| 10 | "net.cleberg.hutch.tip.small", | 38 | transactionUpdatesTask = Task.detached(priority: .background) { |
| 11 | "net.cleberg.hutch.tip.medium", | 39 | for await verification in Transaction.updates { |
| 12 | "net.cleberg.hutch.tip.large" | 40 | guard case .verified(let transaction) = verification else { continue } |
| 13 | ] | 41 | await transaction.finish() |
| 42 | } | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | deinit { | ||
| 47 | transactionUpdatesTask?.cancel() | ||
| 48 | } | ||
| 14 | 49 | ||
| 50 | @MainActor | ||
| 15 | func loadProducts() async { | 51 | func loadProducts() async { |
| 52 | guard !isLoading else { return } | ||
| 53 | |||
| 16 | isLoading = true | 54 | isLoading = true |
| 55 | errorMessage = nil | ||
| 17 | defer { isLoading = false } | 56 | defer { isLoading = false } |
| 57 | |||
| 18 | do { | 58 | do { |
| 19 | let fetched = try await Product.products(for: productIDs) | 59 | let fetched = try await Product.products(for: Self.productIDs) |
| 20 | // Sort by price ascending to maintain small/medium/large order | 60 | let productsByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) }) |
| 21 | products = fetched.sorted { $0.price < $1.price } | 61 | let orderedProducts = TipProduct.allCases.compactMap { productsByID[$0.id] } |
| 62 | let missingProducts = TipProduct.allCases.filter { productsByID[$0.id] == nil } | ||
| 63 | |||
| 64 | products = orderedProducts | ||
| 65 | |||
| 66 | if !missingProducts.isEmpty { | ||
| 67 | let missingNames = missingProducts.map(\.displayName).joined(separator: ", ") | ||
| 68 | 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." | ||
| 69 | } | ||
| 22 | } catch { | 70 | } catch { |
| 23 | errorMessage = error.localizedDescription | 71 | products = [] |
| 72 | errorMessage = "Couldn't load tips from the App Store. \(error.localizedDescription)" | ||
| 24 | } | 73 | } |
| 25 | } | 74 | } |
| 26 | 75 | ||
| 76 | @MainActor | ||
| 27 | func purchase(_ product: Product) async { | 77 | func purchase(_ product: Product) async { |
| 78 | guard purchasingProductID == nil else { return } | ||
| 79 | |||
| 80 | purchasingProductID = product.id | ||
| 81 | errorMessage = nil | ||
| 82 | statusMessage = nil | ||
| 83 | defer { purchasingProductID = nil } | ||
| 84 | |||
| 28 | do { | 85 | do { |
| 29 | let result = try await product.purchase() | 86 | let result = try await product.purchase() |
| 30 | switch result { | 87 | switch result { |
| 31 | case .success(let verification): | 88 | case .success(let verification): |
| 32 | if case .verified(let transaction) = verification { | 89 | switch verification { |
| 90 | case .verified(let transaction): | ||
| 33 | await transaction.finish() | 91 | await transaction.finish() |
| 92 | statusMessage = "Purchase completed successfully." | ||
| 93 | case .unverified(_, let error): | ||
| 94 | errorMessage = "The App Store returned an unverified transaction. \(error.localizedDescription)" | ||
| 34 | } | 95 | } |
| 35 | case .userCancelled, .pending: | 96 | |
| 97 | case .pending: | ||
| 98 | statusMessage = "Purchase is pending approval." | ||
| 99 | |||
| 100 | case .userCancelled: | ||
| 36 | break | 101 | break |
| 102 | |||
| 37 | @unknown default: | 103 | @unknown default: |
| 38 | break | 104 | errorMessage = "The App Store returned an unknown purchase result." |
| 39 | } | 105 | } |
| 40 | } catch { | 106 | } catch { |
| 41 | errorMessage = error.localizedDescription | 107 | errorMessage = "Purchase failed. \(error.localizedDescription)" |
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | @MainActor | ||
| 112 | func restorePurchases() async { | ||
| 113 | guard !isRestoringPurchases else { return } | ||
| 114 | |||
| 115 | isRestoringPurchases = true | ||
| 116 | errorMessage = nil | ||
| 117 | defer { isRestoringPurchases = false } | ||
| 118 | |||
| 119 | do { | ||
| 120 | try await AppStore.sync() | ||
| 121 | statusMessage = "Purchase history synced with the App Store." | ||
| 122 | await loadProducts() | ||
| 123 | } catch { | ||
| 124 | errorMessage = "Couldn't sync purchases. \(error.localizedDescription)" | ||
| 42 | } | 125 | } |
| 43 | } | 126 | } |
| 127 | |||
| 128 | @MainActor | ||
| 129 | func clearStatusMessage() { | ||
| 130 | statusMessage = nil | ||
| 131 | } | ||
| 132 | |||
| 133 | func isPurchasing(_ product: Product) -> Bool { | ||
| 134 | purchasingProductID == product.id | ||
| 135 | } | ||
| 44 | } | 136 | } |
HutchTests/TipStoreViewModelTests.swift added +22
| @@ -0,0 +1,22 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | struct TipStoreViewModelTests { | ||
| 6 | |||
| 7 | @Test | ||
| 8 | func tipProductIdentifiersMatchAppStoreConnectOrder() { | ||
| 9 | #expect(TipStoreViewModel.productIDs == [ | ||
| 10 | "net.cleberg.hutch.tip.small", | ||
| 11 | "net.cleberg.hutch.tip.medium", | ||
| 12 | "net.cleberg.hutch.tip.large", | ||
| 13 | ]) | ||
| 14 | } | ||
| 15 | |||
| 16 | @Test | ||
| 17 | func tipProductDisplayNamesMatchReviewMetadata() { | ||
| 18 | #expect(TipStoreViewModel.TipProduct.small.displayName == "Small Tip") | ||
| 19 | #expect(TipStoreViewModel.TipProduct.medium.displayName == "Medium Tip") | ||
| 20 | #expect(TipStoreViewModel.TipProduct.large.displayName == "Large Tip") | ||
| 21 | } | ||
| 22 | } | ||
SyncedProducts.storekit added +87
| @@ -0,0 +1,87 @@ | |||
| 1 | { | ||
| 2 | "appPolicies" : { | ||
| 3 | "eula" : "", | ||
| 4 | "policies" : [ | ||
| 5 | { | ||
| 6 | "locale" : "en_US", | ||
| 7 | "policyText" : "", | ||
| 8 | "policyURL" : "" | ||
| 9 | } | ||
| 10 | ] | ||
| 11 | }, | ||
| 12 | "identifier" : "8055EC65", | ||
| 13 | "nonRenewingSubscriptions" : [ | ||
| 14 | |||
| 15 | ], | ||
| 16 | "products" : [ | ||
| 17 | { | ||
| 18 | "displayPrice" : "4.99", | ||
| 19 | "familyShareable" : false, | ||
| 20 | "internalID" : "6762209132", | ||
| 21 | "localizations" : [ | ||
| 22 | { | ||
| 23 | "description" : "Large Tip", | ||
| 24 | "displayName" : "Large Tip", | ||
| 25 | "locale" : "en_US" | ||
| 26 | } | ||
| 27 | ], | ||
| 28 | "productID" : "net.cleberg.hutch.tip.large", | ||
| 29 | "referenceName" : "Large Tip", | ||
| 30 | "type" : "Consumable" | ||
| 31 | }, | ||
| 32 | { | ||
| 33 | "displayPrice" : "2.99", | ||
| 34 | "familyShareable" : false, | ||
| 35 | "internalID" : "6762209073", | ||
| 36 | "localizations" : [ | ||
| 37 | { | ||
| 38 | "description" : "Medium Tip", | ||
| 39 | "displayName" : "Medium Tip", | ||
| 40 | "locale" : "en_US" | ||
| 41 | } | ||
| 42 | ], | ||
| 43 | "productID" : "net.cleberg.hutch.tip.medium", | ||
| 44 | "referenceName" : "Medium Tip", | ||
| 45 | "type" : "Consumable" | ||
| 46 | }, | ||
| 47 | { | ||
| 48 | "displayPrice" : "0.99", | ||
| 49 | "familyShareable" : false, | ||
| 50 | "internalID" : "6762209018", | ||
| 51 | "localizations" : [ | ||
| 52 | { | ||
| 53 | "description" : "Small Tip", | ||
| 54 | "displayName" : "Small Tip", | ||
| 55 | "locale" : "en_US" | ||
| 56 | } | ||
| 57 | ], | ||
| 58 | "productID" : "net.cleberg.hutch.tip.small", | ||
| 59 | "referenceName" : "Small Tip", | ||
| 60 | "type" : "Consumable" | ||
| 61 | } | ||
| 62 | ], | ||
| 63 | "settings" : { | ||
| 64 | "_applicationInternalID" : "6760742299", | ||
| 65 | "_askToBuyEnabled" : false, | ||
| 66 | "_billingGracePeriodEnabled" : false, | ||
| 67 | "_billingIssuesEnabled" : false, | ||
| 68 | "_developerTeamID" : "ZCNAX3VL9D", | ||
| 69 | "_disableDialogs" : false, | ||
| 70 | "_failTransactionsEnabled" : false, | ||
| 71 | "_lastSynchronizedDate" : 798317262.14421904, | ||
| 72 | "_locale" : "en_US", | ||
| 73 | "_renewalBillingIssuesEnabled" : false, | ||
| 74 | "_storefront" : "USA", | ||
| 75 | "_storeKitErrors" : [ | ||
| 76 | |||
| 77 | ], | ||
| 78 | "_timeRate" : 0 | ||
| 79 | }, | ||
| 80 | "subscriptionGroups" : [ | ||
| 81 | |||
| 82 | ], | ||
| 83 | "version" : { | ||
| 84 | "major" : 5, | ||
| 85 | "minor" : 0 | ||
| 86 | } | ||
| 87 | } | ||