Manga Kotoba, Part 5: SwiftUI Polish, StoreKit Paywall, and Shipping to the App Store
Five parts, seven months, one shipped app. This is the finale of the Manga Kotoba series — the one where we stop building and start shipping. We'll polish the SwiftUI layer, wire up StoreKit 2 for subscriptions, fight our way through App Store review, and reflect on what it all meant.
--- ## 1. Where We Left Off
Series recap
The app was working at the end of Part 4 — you could sign in, browse manga, study vocabulary cards with spaced repetition. But "working" and "App Store ready" are not the same thing. Missing: monetisation, a real onboarding flow, polished animations, and the courage to actually submit.
---
## 2. SwiftUI Component Gallery
{: class="marginalia" }
SwiftUI previews in Xcode 15+ are dramatically faster than the old simulator-based ones. I ran the canvas at 1× on a Mac Studio and got near-instant refreshes for most components.
Below is an interactive mock of the four main screens. Click the tab bar icons to switch. This runs entirely in the browser to give you a feel for the component architecture.
- Part 1 — Architecture decisions: SwiftUI + Symfony 7 + API Platform. The vocabulary extraction pipeline for manga pages.
- Part 2 — Building the API: JWT auth, resource classes, custom state processors, OpenAPI docs.
- Part 3 — SwiftUI foundations:
NavigationStack, async data fetching, the firstVocabCardcomponent. - Part 4 — Production backend on Heroku + RDS, PostgreSQL migrations, staging/production environments, the vocabulary spaced-repetition engine (SM-2).
- Part 5 (this post) — UI polish, StoreKit 2 paywall, onboarding, App Store submission and all the ways it went wrong.
N3
仲間
なかま (nakama)
Companion; comrade; fellow
"俺の仲間を傷つけるな!"
← skip
know →
Click card to simulate swipe
My Manga
🗡️
Demon Slayer
1,204 words
🍥
Naruto
3,891 words
⚔️
One Piece
5,120 words
🔒
Unlock Pro
Go Pro
Today's Progress
Daily goal: 20 words
Click to simulate studying a card
Settings
Dark Mode
Daily Notifications
Romaji hints
Sound effects
Manga Kotoba v2.1.0
// ❌ Wrong — no directional info VocabCardView(card: card) .onTapGesture { // We know it was tapped, but left or right? viewModel.markKnown(card) }Obvious in hindsight. `onTapGesture` gives you a tap location, but we need direction. ### Attempt 2: DragGesture with .onEnded
// ⚠️ Better, but no real-time feedback VocabCardView(card: card) .gesture( DragGesture() .onEnded { value in if value.translation.width > 50 { viewModel.markKnown(card) } else if value.translation.width < -50 { viewModel.markSkipped(card) } } )Works, but the card doesn't visually tilt while dragging. Feels unresponsive. ### Final: DragGesture with .onChanged + .onEnded {: class="marginalia" } The `.onChanged` closure fires every time the finger moves. Combining it with `withAnimation` keeps SwiftUI's layout engine happy and avoids dropped frames. On a real device this stays at 120 fps.
struct SwipeableCard: View { let card: VocabCard var onSwipe: (SwipeDirection) -> Void @State private var dragOffset: CGSize = .zero @State private var isDragging = false private var rotation: Double { Double(dragOffset.width / 20) } private var swipeIndicator: SwipeDirection? { if dragOffset.width > 60 { return .right } if dragOffset.width < -60 { return .left } return nil } var body: some View { ZStack { cardContent if let dir = swipeIndicator { SwipeLabel(direction: dir) .transition(.opacity) } } .rotationEffect(.degrees(rotation)) .offset(dragOffset) .gesture( DragGesture() .onChanged { value in withAnimation(.interactiveSpring()) { dragOffset = value.translation isDragging = true } } .onEnded { value in let threshold: CGFloat = 100 if value.translation.width > threshold { flyOut(to: .right) } else if value.translation.width < -threshold { flyOut(to: .left) } else { withAnimation(.spring()) { dragOffset = .zero } } isDragging = false } ) } private func flyOut(to direction: SwipeDirection) { let xTarget: CGFloat = direction == .right ? 500 : -500 withAnimation(.easeIn(duration: 0.25)) { dragOffset = CGSize(width: xTarget, height: 0) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { onSwipe(direction) } } }### Interactive Drag Demo Drag the card below left or right (or click the buttons):
勇気
ゆうき (yūki)
Courage; bravery
"勇気を出して!" — Be brave!
Drag left (skip) or right (know)
actor StoreService { static let productIDs = [ "com.mangakotoba.pro.monthly", "com.mangakotoba.pro.yearly" ] private(set) var products: [Product] = [] func loadProducts() async throws { products = try await Product.products(for: Self.productIDs) } }### Purchasing and Handling Result
func purchase(_ product: Product) async { do { let result = try await product.purchase() switch result { case .success(let verification): switch verification { case .verified(let transaction): await transaction.finish() await updateProStatus() case .unverified: // JWS signature check failed — treat as no purchase break } case .userCancelled, .pending: break @unknown default: break } } catch { print("Purchase error: " + error.localizedDescription) } }### Checking Active Entitlements
func updateProStatus() async { var isActive = false for await result in Transaction.currentEntitlements { if case .verified(let tx) = result, tx.productType == .autoRenewableSubscription { isActive = true } } await MainActor.run { isPro = isActive } }No more OpenSSL. No more base64-decoding receipt blobs. The entire entitlement check is four lines of async Swift. ### Interactive Paywall Demo
Unlock Manga Kotoba Pro
Unlimited manga · Offline mode · Advanced stats
Monthly
$2.99
per month
Annual
$19.99
per year
Save 44%
Restore Purchases
⏳ Purchasing…
🎉
You're Pro!
Enjoy unlimited manga vocabulary.
🇯🇵
Welcome to Manga Kotoba
Learn Japanese vocabulary through the manga you already love. Real words, real context, real retention.
5 quick steps
Pick your manga
Choose 3 titles to start with. You can add more later.
One Piece
Demon Slayer
Naruto
My Hero Academia
Berserk
Spy x Family
Daily goal
How many words per day? Consistency beats intensity.
10
words per day
510152030
🔔
Stay on track
Enable daily reminders to keep your streak alive. We'll only ping you once a day.
🚀
You're ready!
Your journey to reading manga in Japanese starts now.
TabView(selection: $onboardingPage) { WelcomeView().tag(0) MangaPickerView().tag(1) DailyGoalView().tag(2) NotificationsView().tag(3) ReadyView().tag(4) } .tabViewStyle(.page(indexDisplayMode: .never)) .fullScreenCover(isPresented: $showOnboarding) { ... }The `showOnboarding` flag is stored in `@AppStorage("hasCompletedOnboarding")` — it defaults to `false` on first launch and is set to `true` when the user taps "Start". --- ## 6. App Store Submission Horror Stories The app was ready in early April. Submission was… not clean. ### Rejection 1: Missing NSCameraUsageDescription
Rejection reason: "This app requires access to the camera. Please provide a usage description in your Info.plist."
The problem: We don't use the camera. But our Symfony API was serving an image picker component that referenced
The fix: Added
### Rejection 2: "App Does Not Function As Advertised"
The problem: We don't use the camera. But our Symfony API was serving an image picker component that referenced
AVFoundation indirectly through a library. The iOS app's Swift Package dependency graph pulled in a package that declared a camera capability in its own Info.plist fragment, which Apple's binary analysis flagged.The fix: Added
NSCameraUsageDescription with "This app does not use the camera. This key is required by an indirect dependency." Resubmitted. Approved.
Rejection reason: "We were unable to sign in with the credentials provided. The app appears to require a valid account to access its core functionality."
The problem: We'd been developing against staging (api-staging.mangakotoba.io) and shipped the production binary (api.mangakotoba.io). The test account credentials in the submission notes were for the staging environment. The reviewer tried to log in to production with a staging-only account. It failed.
The fix: Created a dedicated App Review account on production, updated submission notes with the new credentials, triple-checked. Resubmitted.
### The 3-Day Human Review Wait
After automated checks passed (usually ~30 minutes), we sat in "Waiting for Review" for 68 hours before a human looked at it. This is normal for first-time app submissions. After that initial friction, subsequent updates reviewed in under 8 hours.
### Rejection 3: Screenshot Metadata Mismatch
The problem: We'd been developing against staging (api-staging.mangakotoba.io) and shipped the production binary (api.mangakotoba.io). The test account credentials in the submission notes were for the staging environment. The reviewer tried to log in to production with a staging-only account. It failed.
The fix: Created a dedicated App Review account on production, updated submission notes with the new credentials, triple-checked. Resubmitted.
Rejection reason (metadata, not binary): "Your screenshots do not accurately represent the app's current UI."
The screenshots were from a design mockup created two sprints before the final UI. The paywall screen in the screenshots had a "Start 7-day Free Trial" button; the actual app shipped with "Start Free Trial" (we removed the 7-day duration wording after legal review). One reviewer noticed.
The fix: Re-shot all screenshots with Xcode Simulator on iPhone 15 Pro, uploaded, resubmitted metadata only (no binary needed).
### Lessons Learned
- **Test App Review accounts on production before submitting.** This is obvious in retrospect.
- **Audit your dependency tree** for privacy manifest keys. Even if *your* code doesn't use a capability, your dependencies might declare one.
- **Screenshots should be taken from the final binary**, not mockups. Automate this if you can (Fastlane's `snapshot` tool).
- **Metadata changes don't require binary resubmission**, but they still go through review. Budget time.
- **Keep an App Store rejection log.** It becomes an invaluable reference for the next app.
---
## 7. "Would I Do It Again?" Retrospective
{: class="marginalia" }
The App Store takes a 30% cut (15% for small developers under $1M/year revenue via the Small Business Program). For a $2.99/month subscription, you net ~$2.09 after Apple's cut.
The screenshots were from a design mockup created two sprints before the final UI. The paywall screen in the screenshots had a "Start 7-day Free Trial" button; the actual app shipped with "Start Free Trial" (we removed the 7-day duration wording after legal review). One reviewer noticed.
The fix: Re-shot all screenshots with Xcode Simulator on iPhone 15 Pro, uploaded, resubmitted metadata only (no binary needed).
Part 1 — Architecture
🏗️ Chose SwiftUI + Symfony 7 + API Platform. Built the vocabulary extraction pipeline.
Part 2 — API
🔌 JWT auth, API Platform resources, custom state processors, OpenAPI docs.
Part 3 — SwiftUI Core
📱 NavigationStack, async data layer, first VocabCard. The app became real.
Part 4 — Production
🚀 Heroku + RDS, staging environment, SM-2 spaced repetition engine.
Part 5 — Shipped
🎉 StoreKit 2, onboarding, polish, 3 rejections, and finally: live on the App Store.
How do I feel about building this?
😩 Never again
🚀 Absolutely
Worth it — but I'd do it differently. The Symfony backend with API Platform was the right call: the OpenAPI docs alone saved me hours of debugging the Swift HTTP layer. I'd keep it. For the iOS side, I'm proud of the SwiftUI architecture, but I underestimated how much Apple-specific ceremony (provisioning, App Store Connect, review) would dominate the last 20% of development time. Next time I'd budget two weeks just for that.
- Android version — The backend is already there. The question is native Kotlin vs React Native. Leaning React Native for speed.
- More manga — The current pipeline requires manual QA per manga. A self-serve submission form (or a web scraping pipeline with human review) would scale this to hundreds of titles.
- Community features — Shared word lists, user annotations, "this word appeared in chapter 47 of X" context links.
- Open-sourcing the backend — The vocabulary engine and API layer are reasonably generic. Considering it under MIT once the business model is validated.
- Sentence mining mode — Anki-style sentence cards generated automatically from manga panels, with audio via a TTS API.
Thanks for following along through all five parts. If you built something with this series, I'd love to hear about it — @malukenho on Twitter/X.