···11+---
22+title: Phase 7 Spec
33+updated: 2026-04-04
44+---
55+66+## Monetization — Inline Ads & Tips
77+88+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.
99+1010+### Inline Native Ads
1111+1212+Ads are rendered as `NativeAd` (Google AdMob) styled to match `PostCard` dimensions so they feel like organic content rather than interruptions.
1313+1414+**Placement rules:**
1515+1616+- **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.
1717+- **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).
1818+- **Grid layout:** Ad occupies a single grid cell, matching the card aspect ratio.
1919+- **Linear layout:** Ad renders at full width between post cards with a subtle "Sponsored" label and muted divider.
2020+- **No ads in:** Replies tab, Media tab, Lists, Starter Packs, DMs, Notifications, Compose, Settings, Social Graph.
2121+2222+**Ad lifecycle:**
2323+2424+1. `MobileAds.instance.initialize()` called once during app bootstrap (after auth, before first frame).
2525+2. Ads are pre-fetched one page ahead — when the feed loads page N, request ads for page N+1 slots.
2626+3. Each `NativeAd` is created with `NativeTemplateStyle(templateType: TemplateType.medium)` styled to match the app's surface colors and typography.
2727+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.
2828+5. `NativeAd.dispose()` is called when the ad scrolls far off-screen (hybrid lifecycle: create on approach, dispose on distance).
2929+6. If an ad fails to load (`onAdFailedToLoad`), the slot collapses — no blank space, no retry for that position.
3030+3131+**Ad-free flag:**
3232+3333+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.
3434+3535+**Platform configuration:**
3636+3737+| Platform | Requirement |
3838+| -------- | -------------------------------------------------------------------------------------------------------- |
3939+| iOS | `GADApplicationIdentifier` in `Info.plist`, `NSUserTrackingUsageDescription` for ATT, `SKAdNetworkItems` |
4040+| Android | `com.google.android.gms.ads.APPLICATION_ID` meta-data in `AndroidManifest.xml` |
4141+4242+Package: `google_mobile_ads: ^7.0.0`
4343+4444+### In-App Purchases (Tips)
4545+4646+Two consumable tip products let users support the app repeatedly. The first completed purchase of _either_ tip also flips `adsRemoved = true`, removing ads forever.
4747+4848+**Products:**
4949+5050+| ID | Display Name | Price | Type |
5151+| ------------ | ------------ | ----- | ---------- |
5252+| `tip_coffee` | Coffee | $1.99 | Consumable |
5353+| `tip_latte` | Latte | $4.99 | Consumable |
5454+5555+Both are consumable so they can be purchased multiple times.
5656+5757+**Ad removal on first purchase:**
5858+5959+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.
6060+6161+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).
6262+6363+**Purchase flow:**
6464+6565+1. `InAppPurchase.instance.isAvailable()` — gate the UI if the store is unreachable.
6666+2. `queryProductDetails({'tip_coffee', 'tip_latte'})` — fetch localized prices.
6767+3. User taps a tip button → `buyConsumable(purchaseParam: ...)`.
6868+4. Listen on `purchaseStream`:
6969+ - `PurchaseStatus.purchased` → set `adsRemoved = true` in DB, call `completePurchase()`.
7070+ - `PurchaseStatus.error` → show snackbar with `error.message`.
7171+ - `PurchaseStatus.pending` → show loading indicator on the button.
7272+5. `completePurchase()` must be called for every terminal purchase to avoid auto-refund (3-day window).
7373+7474+Package: `in_app_purchase: ^3.2.3`
7575+7676+### Tip UI
7777+7878+**Entry point:** "Support Lazurite" row in Settings screen, below theme/layout preferences.
7979+8080+**Tip sheet** (modal bottom sheet):
8181+8282+- Header: app icon + "Support Lazurite"
8383+- Body: two `ListTile`-style rows, each with icon (☕ / ☕☕), product name, localized price from `ProductDetails`, and a "Tip" `FilledButton`.
8484+- 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.
8585+- If `adsRemoved` is `false`: show a note below the rows: "Your first tip removes ads forever."
8686+- Loading state: skeleton tiles while `queryProductDetails` resolves.
8787+- Error state: "Store unavailable" with retry.
8888+8989+### Database Migration
9090+9191+Add column to `Settings` table:
9292+9393+```sql
9494+ALTER TABLE settings ADD COLUMN ads_removed INTEGER NOT NULL DEFAULT 0;
9595+```
9696+9797+Migration index: next sequential migration in `AppDatabase`.
9898+9999+### Ad Helper
100100+101101+`AdHelper` utility class providing:
102102+103103+- `nativeAdUnitId` — returns platform-appropriate ad unit ID (test IDs in debug, real IDs in release).
104104+- `adInterval` — `8` (posts between ads).
105105+- `profileAdOffset` — `4` (delay before first ad on profile).
106106+107107+Test ad unit IDs (Google-provided):
108108+109109+- iOS: `ca-app-pub-3940256099942544/3986624511`
110110+- Android: `ca-app-pub-3940256099942544/2247696110`
+95
docs/tasks/phase-7.md
···11+---
22+title: Phase 7 Task Breakdown
33+updated: 2026-04-04
44+---
55+66+# Phase 7 Milestones
77+88+## M26 - Inline Native Ads
99+1010+### Core - Ad Infrastructure
1111+1212+- [ ] Add `google_mobile_ads: ^7.0.0` to `pubspec.yaml`
1313+- [ ] iOS: add `GADApplicationIdentifier`, `NSUserTrackingUsageDescription`, `SKAdNetworkItems` to `Info.plist`
1414+- [ ] Android: add `com.google.android.gms.ads.APPLICATION_ID` meta-data to `AndroidManifest.xml`
1515+- [ ] `AdHelper` utility — platform-aware ad unit IDs (test in debug, real in release), `adInterval = 8`, `profileAdOffset = 4`
1616+- [ ] Call `MobileAds.instance.initialize()` in app bootstrap, gated on `!adsRemoved`
1717+1818+### Core - Database
1919+2020+- [ ] Drift migration: add `adsRemoved` (`INTEGER NOT NULL DEFAULT 0`) column to `Settings` table
2121+- [ ] `SettingsCubit` — expose `adsRemoved` flag, `setAdsRemoved(bool)` method
2222+2323+### Cubit
2424+2525+- [ ] `AdCubit` — manages ad loading lifecycle per feed/profile instance
2626+- [ ] `AdState` — fields: `Map<int, NativeAd> loadedAds` (keyed by slot index), `adsRemoved`
2727+- [ ] `loadAdsForPage(int pageIndex, int postCount)` — pre-fetches `NativeAd` instances for calculated slot positions
2828+- [ ] `disposeAd(int slotIndex)` — dispose ads scrolled far off-screen
2929+- [ ] Skip all ad operations when `adsRemoved == true`
3030+3131+### UI - Feed Ads
3232+3333+- [ ] `FeedLayoutView` — adjust `itemCount` to include ad slots at every `adInterval` posts
3434+- [ ] Index mapping — `visualIndex → dataIndex` translation accounting for injected ad slots
3535+- [ ] `AdPostCard` widget — wraps `AdWidget` + `NativeAd` in a card matching `PostCard` dimensions, "Sponsored" label
3636+- [ ] Linear layout: full-width ad card with muted dividers
3737+- [ ] Grid layout: ad occupies single grid cell matching card aspect ratio
3838+- [ ] Collapse slot silently on `onAdFailedToLoad` (no blank space)
3939+4040+### UI - Profile Ads
4141+4242+- [ ] Profile posts tab — same ad injection with `profileAdOffset = 4` (first ad appears later)
4343+- [ ] Shared index mapping logic with feed (extract to helper or mixin)
4444+- [ ] No ads in Replies, Media, Lists, or Starter Packs tabs
4545+4646+### Tests
4747+4848+- [ ] Unit tests: `AdHelper` — correct ad unit IDs per platform and build mode
4949+- [ ] Unit tests: `AdCubit` — ad loading, disposal, `adsRemoved` gating, page pre-fetch
5050+- [ ] Unit tests: index mapping — `visualIndex ↔ dataIndex` round-trip for feed and profile offsets
5151+- [ ] Widget tests: `AdPostCard` renders with "Sponsored" label, handles load failure gracefully
5252+- [ ] Widget tests: feed with ads — correct post ordering, ad at expected positions, no ads when `adsRemoved`
5353+- [ ] Widget tests: profile posts — ad offset respected, no ads in non-post tabs
5454+5555+## M27 - In-App Purchase Tips
5656+5757+### Core - Purchase Infrastructure
5858+5959+- [ ] Add `in_app_purchase: ^3.2.3` to `pubspec.yaml`
6060+- [ ] `PurchaseRepository` — wraps `InAppPurchase.instance`
6161+- [ ] `isAvailable()` — checks store reachability
6262+- [ ] `fetchProducts()` — `queryProductDetails({'tip_coffee', 'tip_latte'})`, returns `List<ProductDetails>`
6363+- [ ] `buyTip(ProductDetails)` — calls `buyConsumable(purchaseParam: ...)`
6464+- [ ] `purchaseStream` — exposes `InAppPurchase.instance.purchaseStream`
6565+- [ ] `completePurchase(PurchaseDetails)` — forwards to `InAppPurchase.instance.completePurchase`
6666+6767+### Cubit
6868+6969+- [ ] `TipCubit` — depends on `PurchaseRepository` and `SettingsCubit`
7070+- [ ] `TipState` — fields: `storeStatus` (loading/available/unavailable), `List<ProductDetails> products`, `purchaseStatus` (idle/pending/success/error), `adsRemoved`
7171+- [ ] `loadProducts()` — checks availability, fetches product details
7272+- [ ] `purchaseTip(ProductDetails)` — initiates purchase, listens for result
7373+- [ ] On `PurchaseStatus.purchased` → call `settingsCubit.setAdsRemoved(true)`, then `completePurchase()`
7474+- [ ] On `PurchaseStatus.error` → emit error state with message
7575+- [ ] Subscribe to `purchaseStream` in constructor, handle all terminal states
7676+7777+### UI - Tip Sheet
7878+7979+- [ ] "Support Lazurite" row in Settings screen — opens modal bottom sheet
8080+- [ ] `TipSheet` widget — header with app icon + title
8181+- [ ] Two `ListTile` rows: Coffee (☕ $1.99) and Latte (☕☕ $4.99) with "Tip" `FilledButton`
8282+- [ ] Localized prices from `ProductDetails.price` (not hardcoded)
8383+- [ ] Loading state: skeleton tiles while products load
8484+- [ ] Error state: "Store unavailable" with retry button
8585+- [ ] If `adsRemoved`: thank-you banner above tip rows ("Ads removed — thanks for your support!")
8686+- [ ] If `!adsRemoved`: note below rows ("Your first tip removes ads forever.")
8787+- [ ] Pending state: loading indicator on tapped button, other button disabled
8888+8989+### Tests
9090+9191+- [ ] Unit tests: `PurchaseRepository` — product query, buy consumable, complete purchase, availability check
9292+- [ ] Unit tests: `TipCubit` — product loading, purchase flow (success → ads removed, error → error state, pending → loading), stream subscription
9393+- [ ] Widget tests: `TipSheet` — renders products with localized prices, loading skeleton, error + retry, thank-you banner when ads removed, note when ads not removed
9494+- [ ] Widget tests: Settings screen — "Support Lazurite" row present, opens tip sheet on tap
9595+- [ ] Integration: first purchase sets `adsRemoved = true` in DB, subsequent ad cubit skips loading