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.

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:

  1. The view sets the store's purchaseInFlight flag, which disables every button.
  2. The store calls product.purchase() and awaits the result.
  3. On .success(let verification), the store verifies the transaction signature (checkVerified) and calls .finish(). The UI shows a confirmation.
  4. On .userCancelled, the flag drops; no error surfaces. The buttons re-enable.
  5. On .pending or thrown errors, the store sets an error property 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:

  1. 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.
  2. Modal alert. When a thrown StoreKitError happens 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."
  3. 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

Known gotchas

Sources