Appendix X: TipJar (Shop Manual)
Status: Live on the App Store. GPL v3. Public repo at github.com/fluhartyml/TipJar. Build-Along chapter: Part VII Build-Along 14. TipJar is the book's minimum-viable StoreKit 2 reference — five Swift files, one .storekit configuration, three product tiers, and the full StoreKit 2 purchase / restore / error-handling pipeline visible end-to-end. It's the chapter's Live Reference for Book 06 (custom-styled tip-amount Buttons) and a textbook teaching set for the three layers of error feedback in iOS UI.
What it is
A single-screen iPhone app: the user sees three tip-amount buttons (small / medium / large), taps one, and a StoreKit 2 purchase flow runs. Tapped state disables the buttons while the purchase is in flight; a successful purchase confirms via the system's StoreKit confirmation UI; a failure shows inline feedback rather than a system error. Restore-Purchases is available from a secondary action. There's no second screen, no settings, no navigation — the app is the buttons.
Architecture overview
The whole app is five files. Intentionally small — the chapter's job is to make the StoreKit 2 patterns visible without other surface area in the way.
- App entry:
TipJarApp.swift—@mainscene hostingContentView. - UI:
ContentView.swift— the three tip-amount buttons, the in-flight disabled state, the Restore action, and the three error-feedback layers. - Product model:
Item.swift— the local representation of a StoreKit product tier (small / medium / large) with display name, price string, and product identifier. - Store:
TipStore.swift— the@Observablefacade around StoreKit 2'sProduct.products(for:),Product.purchase(...), and the transaction listener. - StoreKit configuration:
TipProducts.storekit— the Xcode-bundled StoreKit configuration file that lets the app run against locally-defined products in the simulator without round-tripping to App Store Connect for every build.
The StoreKit 2 flow
On launch, TipStore calls Product.products(for:) with the three product identifiers and stores the returned Product values for the UI. The view binds each button to one product. On tap:
- The view sets the store's
purchaseInFlightflag, which disables every button. - The store calls
product.purchase()andawaits the result. - On
.success(let verification), the store verifies the transaction signature (checkVerified) and calls.finish(). The UI shows a confirmation. - On
.userCancelled, the flag drops; no error surfaces. The buttons re-enable. - On
.pendingor thrown errors, the store sets anerrorproperty the UI shows.
A background Task attached at TipStore init listens to Transaction.updates for refunds, family-sharing changes, and out-of-app transactions. The same listener handles Restore Purchases — tapping Restore calls AppStore.sync() and lets the listener route the resulting transactions.
Three layers of error feedback
The teaching set captured in three App Store screenshots (2026-05-02) walks through how this app surfaces problems:
- Loading state. While
Product.products(for:)is fetching the catalog at launch, the buttons show a placeholder skeleton. No buttons render until the products resolve. This is the cheapest, lowest-friction "the app is doing something" signal. - Modal alert. When a thrown
StoreKitErrorhappens mid-purchase (network, account, server), the app shows a SwiftUI.alert(...)with the localized error message. Modal alerts are reserved for situations where the user did something and the system has to interrupt to say "that didn't work." - Inline empty-state with retry. If
Product.products(for:)returns an empty array at launch (no products configured, account not signed in, StoreKit configuration missing on a dev build), the view shows an inline empty state with a Retry button styled.borderedProminent. Tapping Retry re-runs the products fetch. The inline state is non-modal — the user can still see the app, leave, come back later — because the failure isn't tied to an action they just took.
The three layers correspond to three error scopes: ambient (loading), user-action (alert), structural (inline retry). Each chapter that touches error handling can refer back to this app as the canonical worked example.
Custom button styling
The tip-amount buttons are not stock .borderedProminent. Each button is a Button wrapping a custom HStack with the amount label, a tip-jar SF Symbol, and a price string — the closest tap target is the whole button surface. Background, corner radius, shadow, and pressed-state animation are all view modifiers chained onto the button label, not a custom ButtonStyle. The .disabled(store.purchaseInFlight) modifier handles the in-flight state via SwiftUI's standard disabled appearance.
Why custom styling instead of a ButtonStyle? The buttons need different visual treatments per tier (different SF Symbols, different accent tints) so the per-button modifier chain reads more directly than threading state into a shared ButtonStyle. The trade-off is a slightly bigger view body in ContentView in exchange for a more legible mapping from product to UI.
Build + deployment
- App name: TipJar.
- Platforms: iPhone (iPad and Mac inherit via Designed for iPad).
- Frameworks: SwiftUI, StoreKit (StoreKit 2 in-app purchase).
- Networking: StoreKit's own. No custom
URLSession, no analytics, no tracking. - Privacy: No personal data collected.
- Subscription disclosure: None — products are non-consumable / consumable one-time purchases, not subscriptions.
Known gotchas
- The local
.storekitfile only works in Xcode's StoreKit Configuration scheme. Running against the local file means simulator purchases succeed without hitting App Store Connect, which is great for dev. But every release build has to be tested against the real App Store Connect products to verify the IDs match exactly. A mismatch produces the inline empty-state at launch in production — which the Retry button can't fix because the products genuinely don't exist for that account. - Transaction listener has to start at launch. If the listener starts only when the user taps a button, an out-of-app transaction (Family Sharing, refund) that happens between launches goes unhandled. The store kicks off the listener task in
initprecisely to avoid that gap. - Restore Purchases should be visible. App Review explicitly checks for a Restore Purchases path in apps that sell anything. The Restore action is wired to
AppStore.sync(); without that action, review can reject the binary. - The "empty state at launch" failure mode is real. If a user opens the app on an Apple Account that has no payment method on file, or in a region where the app's products aren't available, the products array returns empty. The inline retry state handles this; without it, the app would look broken.
Sources
- Project source: github.com/fluhartyml/TipJar (verified 2026-05-10).
- Build-Along chapter: Part VII Build-Along 14.
- Book 06 cross-reference: custom-styled
Button+.borderedProminenton Retry +.disabledin-flight pattern. - Book 18 cross-reference: the three-layers-of-error-feedback teaching set.
- Book 16 cross-reference (limited): how a small focused app uses StoreKit without an extension target.