From 89011efc2885faa8566c08ec1966dc2d14833e65 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 4 Apr 2026 23:09:50 -0500 Subject: [PATCH] docs: monetization plan --- docs/specs/phase-7.md | 110 ++++++++++++++++++++++++++++++++++++++++++ docs/tasks/phase-7.md | 95 ++++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 docs/specs/phase-7.md create mode 100644 docs/tasks/phase-7.md diff --git a/docs/specs/phase-7.md b/docs/specs/phase-7.md new file mode 100644 index 0000000..badf851 --- /dev/null +++ b/docs/specs/phase-7.md @@ -0,0 +1,110 @@ +--- +title: Phase 7 Spec +updated: 2026-04-04 +--- + +## Monetization — Inline Ads & Tips + +Revenue layer for iOS and Android: native ads that blend into content feeds, and repeatable tip purchases. First purchase of any tip removes ads permanently. + +### Inline Native Ads + +Ads are rendered as `NativeAd` (Google AdMob) styled to match `PostCard` dimensions so they feel like organic content rather than interruptions. + +**Placement rules:** + +- **Feed:** Insert one ad every 8 posts (configurable via `adInterval` constant). Position is deterministic per feed page — same scroll position always shows the ad slot, no layout jank. +- **Profile posts tab:** Same cadence, offset by 4 so the first ad appears later (the user came to see _this person's_ posts, not ads). +- **Grid layout:** Ad occupies a single grid cell, matching the card aspect ratio. +- **Linear layout:** Ad renders at full width between post cards with a subtle "Sponsored" label and muted divider. +- **No ads in:** Replies tab, Media tab, Lists, Starter Packs, DMs, Notifications, Compose, Settings, Social Graph. + +**Ad lifecycle:** + +1. `MobileAds.instance.initialize()` called once during app bootstrap (after auth, before first frame). +2. Ads are pre-fetched one page ahead — when the feed loads page N, request ads for page N+1 slots. +3. Each `NativeAd` is created with `NativeTemplateStyle(templateType: TemplateType.medium)` styled to match the app's surface colors and typography. +4. `AdWidget` is inserted into the feed's item builder at calculated indices. The builder adjusts `itemCount` and maps visual indices back to data indices. +5. `NativeAd.dispose()` is called when the ad scrolls far off-screen (hybrid lifecycle: create on approach, dispose on distance). +6. If an ad fails to load (`onAdFailedToLoad`), the slot collapses — no blank space, no retry for that position. + +**Ad-free flag:** + +A boolean `adsRemoved` in the Drift `Settings` table. When `true`, the ad item builder is skipped entirely — no `MobileAds.initialize()`, no network requests, no ad widgets. + +**Platform configuration:** + +| Platform | Requirement | +| -------- | -------------------------------------------------------------------------------------------------------- | +| iOS | `GADApplicationIdentifier` in `Info.plist`, `NSUserTrackingUsageDescription` for ATT, `SKAdNetworkItems` | +| Android | `com.google.android.gms.ads.APPLICATION_ID` meta-data in `AndroidManifest.xml` | + +Package: `google_mobile_ads: ^7.0.0` + +### In-App Purchases (Tips) + +Two consumable tip products let users support the app repeatedly. The first completed purchase of _either_ tip also flips `adsRemoved = true`, removing ads forever. + +**Products:** + +| ID | Display Name | Price | Type | +| ------------ | ------------ | ----- | ---------- | +| `tip_coffee` | Coffee | $1.99 | Consumable | +| `tip_latte` | Latte | $4.99 | Consumable | + +Both are consumable so they can be purchased multiple times. + +**Ad removal on first purchase:** + +Rather than a separate non-consumable "Remove Ads" SKU, we track whether the user has _ever_ completed a tip. This keeps the store listing simple (two products, not three) and gives the tip a tangible reward beyond goodwill. + +Persistence: `adsRemoved` flag in Drift `Settings` table, set to `true` on first successful purchase completion. Since the flag is local-only, a "Restore Purchases" flow is not needed — consumables are not restorable via store APIs, and the flag is durable across app updates. On a fresh install the user sees ads again (acceptable trade-off vs. running a verification server). + +**Purchase flow:** + +1. `InAppPurchase.instance.isAvailable()` — gate the UI if the store is unreachable. +2. `queryProductDetails({'tip_coffee', 'tip_latte'})` — fetch localized prices. +3. User taps a tip button → `buyConsumable(purchaseParam: ...)`. +4. Listen on `purchaseStream`: + - `PurchaseStatus.purchased` → set `adsRemoved = true` in DB, call `completePurchase()`. + - `PurchaseStatus.error` → show snackbar with `error.message`. + - `PurchaseStatus.pending` → show loading indicator on the button. +5. `completePurchase()` must be called for every terminal purchase to avoid auto-refund (3-day window). + +Package: `in_app_purchase: ^3.2.3` + +### Tip UI + +**Entry point:** "Support Lazurite" row in Settings screen, below theme/layout preferences. + +**Tip sheet** (modal bottom sheet): + +- Header: app icon + "Support Lazurite" +- Body: two `ListTile`-style rows, each with icon (☕ / ☕☕), product name, localized price from `ProductDetails`, and a "Tip" `FilledButton`. +- If `adsRemoved` is already `true`: show a thank-you note ("Ads removed — thanks for your support!") above the tip rows. Tips remain available for repeat purchases. +- If `adsRemoved` is `false`: show a note below the rows: "Your first tip removes ads forever." +- Loading state: skeleton tiles while `queryProductDetails` resolves. +- Error state: "Store unavailable" with retry. + +### Database Migration + +Add column to `Settings` table: + +```sql +ALTER TABLE settings ADD COLUMN ads_removed INTEGER NOT NULL DEFAULT 0; +``` + +Migration index: next sequential migration in `AppDatabase`. + +### Ad Helper + +`AdHelper` utility class providing: + +- `nativeAdUnitId` — returns platform-appropriate ad unit ID (test IDs in debug, real IDs in release). +- `adInterval` — `8` (posts between ads). +- `profileAdOffset` — `4` (delay before first ad on profile). + +Test ad unit IDs (Google-provided): + +- iOS: `ca-app-pub-3940256099942544/3986624511` +- Android: `ca-app-pub-3940256099942544/2247696110` diff --git a/docs/tasks/phase-7.md b/docs/tasks/phase-7.md new file mode 100644 index 0000000..a74789d --- /dev/null +++ b/docs/tasks/phase-7.md @@ -0,0 +1,95 @@ +--- +title: Phase 7 Task Breakdown +updated: 2026-04-04 +--- + +# Phase 7 Milestones + +## M26 - Inline Native Ads + +### Core - Ad Infrastructure + +- [ ] Add `google_mobile_ads: ^7.0.0` to `pubspec.yaml` +- [ ] iOS: add `GADApplicationIdentifier`, `NSUserTrackingUsageDescription`, `SKAdNetworkItems` to `Info.plist` +- [ ] Android: add `com.google.android.gms.ads.APPLICATION_ID` meta-data to `AndroidManifest.xml` +- [ ] `AdHelper` utility — platform-aware ad unit IDs (test in debug, real in release), `adInterval = 8`, `profileAdOffset = 4` +- [ ] Call `MobileAds.instance.initialize()` in app bootstrap, gated on `!adsRemoved` + +### Core - Database + +- [ ] Drift migration: add `adsRemoved` (`INTEGER NOT NULL DEFAULT 0`) column to `Settings` table +- [ ] `SettingsCubit` — expose `adsRemoved` flag, `setAdsRemoved(bool)` method + +### Cubit + +- [ ] `AdCubit` — manages ad loading lifecycle per feed/profile instance +- [ ] `AdState` — fields: `Map loadedAds` (keyed by slot index), `adsRemoved` +- [ ] `loadAdsForPage(int pageIndex, int postCount)` — pre-fetches `NativeAd` instances for calculated slot positions +- [ ] `disposeAd(int slotIndex)` — dispose ads scrolled far off-screen +- [ ] Skip all ad operations when `adsRemoved == true` + +### UI - Feed Ads + +- [ ] `FeedLayoutView` — adjust `itemCount` to include ad slots at every `adInterval` posts +- [ ] Index mapping — `visualIndex → dataIndex` translation accounting for injected ad slots +- [ ] `AdPostCard` widget — wraps `AdWidget` + `NativeAd` in a card matching `PostCard` dimensions, "Sponsored" label +- [ ] Linear layout: full-width ad card with muted dividers +- [ ] Grid layout: ad occupies single grid cell matching card aspect ratio +- [ ] Collapse slot silently on `onAdFailedToLoad` (no blank space) + +### UI - Profile Ads + +- [ ] Profile posts tab — same ad injection with `profileAdOffset = 4` (first ad appears later) +- [ ] Shared index mapping logic with feed (extract to helper or mixin) +- [ ] No ads in Replies, Media, Lists, or Starter Packs tabs + +### Tests + +- [ ] Unit tests: `AdHelper` — correct ad unit IDs per platform and build mode +- [ ] Unit tests: `AdCubit` — ad loading, disposal, `adsRemoved` gating, page pre-fetch +- [ ] Unit tests: index mapping — `visualIndex ↔ dataIndex` round-trip for feed and profile offsets +- [ ] Widget tests: `AdPostCard` renders with "Sponsored" label, handles load failure gracefully +- [ ] Widget tests: feed with ads — correct post ordering, ad at expected positions, no ads when `adsRemoved` +- [ ] Widget tests: profile posts — ad offset respected, no ads in non-post tabs + +## M27 - In-App Purchase Tips + +### Core - Purchase Infrastructure + +- [ ] Add `in_app_purchase: ^3.2.3` to `pubspec.yaml` +- [ ] `PurchaseRepository` — wraps `InAppPurchase.instance` +- [ ] `isAvailable()` — checks store reachability +- [ ] `fetchProducts()` — `queryProductDetails({'tip_coffee', 'tip_latte'})`, returns `List` +- [ ] `buyTip(ProductDetails)` — calls `buyConsumable(purchaseParam: ...)` +- [ ] `purchaseStream` — exposes `InAppPurchase.instance.purchaseStream` +- [ ] `completePurchase(PurchaseDetails)` — forwards to `InAppPurchase.instance.completePurchase` + +### Cubit + +- [ ] `TipCubit` — depends on `PurchaseRepository` and `SettingsCubit` +- [ ] `TipState` — fields: `storeStatus` (loading/available/unavailable), `List products`, `purchaseStatus` (idle/pending/success/error), `adsRemoved` +- [ ] `loadProducts()` — checks availability, fetches product details +- [ ] `purchaseTip(ProductDetails)` — initiates purchase, listens for result +- [ ] On `PurchaseStatus.purchased` → call `settingsCubit.setAdsRemoved(true)`, then `completePurchase()` +- [ ] On `PurchaseStatus.error` → emit error state with message +- [ ] Subscribe to `purchaseStream` in constructor, handle all terminal states + +### UI - Tip Sheet + +- [ ] "Support Lazurite" row in Settings screen — opens modal bottom sheet +- [ ] `TipSheet` widget — header with app icon + title +- [ ] Two `ListTile` rows: Coffee (☕ $1.99) and Latte (☕☕ $4.99) with "Tip" `FilledButton` +- [ ] Localized prices from `ProductDetails.price` (not hardcoded) +- [ ] Loading state: skeleton tiles while products load +- [ ] Error state: "Store unavailable" with retry button +- [ ] If `adsRemoved`: thank-you banner above tip rows ("Ads removed — thanks for your support!") +- [ ] If `!adsRemoved`: note below rows ("Your first tip removes ads forever.") +- [ ] Pending state: loading indicator on tapped button, other button disabled + +### Tests + +- [ ] Unit tests: `PurchaseRepository` — product query, buy consumable, complete purchase, availability check +- [ ] Unit tests: `TipCubit` — product loading, purchase flow (success → ads removed, error → error state, pending → loading), stream subscription +- [ ] Widget tests: `TipSheet` — renders products with localized prices, loading skeleton, error + retry, thank-you banner when ads removed, note when ads not removed +- [ ] Widget tests: Settings screen — "Support Lazurite" row present, opens tip sheet on tap +- [ ] Integration: first purchase sets `adsRemoved = true` in DB, subsequent ad cubit skips loading -- 2.51.2