feat(mobile): add flagged contextual paywall sheet and upsell moments
What changed and why
The mobile plans sheet still reads GET v2/payments/plans?source=&trigger=&locale= and accepts only schema_version 2. Backend strings render verbatim. Chrome stays in l10n.
The thirteen fixtures in app/test/fixtures/storefront_v2/ are byte copies of backend/tests/fixtures/storefront_v2/ at lane/sub-w5-presentation-v2 445c5f7f494 (#21397). legacy_neo.json is new. The byte-compare test still checks those copies when the backend directory is present.
Contract the sheet now honors:
- Every
is_currentcard publishesbullets: [], and the sheet renders none. - Switch-to-yearly: the current tier has
action: "checkout", tiercta_label: null, and only the yearly price. The price-levelcta_labelis the button text (Switch to yearly · save $39.98). - Cancel scheduled: the current card publishes only the current price, and
yearly_toggle_label: nullhides the Monthly/Yearly toggle. legacy_neorenders likelegacy_unlimited: current line, legacy note, and Manage billing. No ladder and no checkout.
Flags mobile-plan-storefront and mobile-contextual-upsells stay off. No payment-rail change.
Reconciliation with #21397
#21397 also touches three files under app/:
app/lib/backend/schema/gen/storefront_wire.g.dartapp/test/backend/storefront_wire_test.dartapp/changelog/unreleased/20261013-storefront-presentation-v2.json
Those stay on #21397. The generated DTO is still schema 1 here. Copying the schema 2 generator output without that PR's OpenAPI spec would fail generate_dart_models.py --all --check and export_openapi.py --check. Schema 2 decoding on this branch is PlanStorefront.fromJson, covered by plan_storefront_test.dart.
storefront_wire_test.dart is restored to the schema 1 generated-client test at the merge base, so #21397's rewrite of that file applies cleanly whichever PR merges second. The generated Dart file and the presentation changelog are untouched here for the same reason.
Product invariants affected
none
How it was verified
- Focused Flutter tests passed (57):
test/models/plan_storefront_test.dart,test/widgets/storefront_plans_content_test.dart,test/widgets/storefront_checkout_handoff_test.dart,test/backend/storefront_wire_test.dart,test/unit/plan_storefront_api_test.dart. The handoff test tapsSwitch to yearly · save $39.98and reaches upgrade with the yearly price. Cancel-scheduled asserts the toggle is absent. scripts/l10n.py check: 49 locales, 3876 keys.dart analyzeon the touched Dart files reported no issues.scripts/pr-preflight --lane localpassed 28 checks on this commit; the pre-push gate passed before the push.- Hermetic screenshots were regenerated, one per golden (including
legacy_neo), plus banner, meter, small-phone light, empty, and error. No production API calls, Stripe writes, deploys, or flag flips.
Checkout predicate
storefrontCheckoutDecision decides both the card button and every tap.
- The only same-tier purchase is monthly → annual, and only when the live price interval is month. Annual → monthly and every other same-tier price are refused, including when the current card publishes only the yearly price.
action: "checkout"may omit the tiercta_labelwhen the pricecta_labelcarries the words.action: currentproceeds only when that price has acta_label. A null price id refuses.- Cancel scheduled resubscribes only the current price. Every other price is refused in the predicate, omitted from the sheet, and dropped by the tap guard.
- Price id, interval, and amount must match the existing catalog or checkout is refused.
- Upward moves stay allowed when cancellation is not scheduled. A lower ladder tier, legacy, live unlimited, usage-legacy transcription, and an unrecognized current id are refused.
UI evidence

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/001-mobile-ios-architect-settings.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/002-mobile-ios-cancel-scheduled-plus.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/003-mobile-ios-free-budget-80.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/004-mobile-ios-free-mcp-cap.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/005-mobile-ios-free-pro-gate-proactive.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/006-mobile-ios-free-pro-gate-screen.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/007-mobile-ios-free-settings.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/008-mobile-ios-legacy-unlimited.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/009-mobile-ios-plus-annual-current.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/010-mobile-ios-plus-budget-80.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/011-mobile-ios-plus-monthly-yearly-toggle.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/012-mobile-ios-pro-settings.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/013-mobile-ios-banner.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/014-mobile-ios-meter.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/015-mobile-ios-small-phone-light.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/016-mobile-ios-empty.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/017-mobile-ios-error.png

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/018-mobile-ios-legacy-neo.png
Failure class (fixes)
Failure-Class: none
Reviewer risks
- The generated storefront DTO on this branch is still schema 1. Schema 2 request decoding is
PlanStorefront. #21397 owns the generator output. - Non-English locales show the English deck chrome for the new keys.
plansAndBillingkeeps its previous translations for the old sheet. - A server offer still cannot check out unless the existing price catalog matches id, interval, and amount.
- Flags stay off. Do not enable them before the backend contract is on the serving API.
legacy_unlimitedandlegacy_neoare not drawn as ladder cards. The legacy note is the sheet for those goldens.