mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: monetization plan

+205
+110
docs/specs/phase-7.md
··· 1 + --- 2 + title: Phase 7 Spec 3 + updated: 2026-04-04 4 + --- 5 + 6 + ## Monetization — Inline Ads & Tips 7 + 8 + 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. 9 + 10 + ### Inline Native Ads 11 + 12 + Ads are rendered as `NativeAd` (Google AdMob) styled to match `PostCard` dimensions so they feel like organic content rather than interruptions. 13 + 14 + **Placement rules:** 15 + 16 + - **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. 17 + - **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). 18 + - **Grid layout:** Ad occupies a single grid cell, matching the card aspect ratio. 19 + - **Linear layout:** Ad renders at full width between post cards with a subtle "Sponsored" label and muted divider. 20 + - **No ads in:** Replies tab, Media tab, Lists, Starter Packs, DMs, Notifications, Compose, Settings, Social Graph. 21 + 22 + **Ad lifecycle:** 23 + 24 + 1. `MobileAds.instance.initialize()` called once during app bootstrap (after auth, before first frame). 25 + 2. Ads are pre-fetched one page ahead — when the feed loads page N, request ads for page N+1 slots. 26 + 3. Each `NativeAd` is created with `NativeTemplateStyle(templateType: TemplateType.medium)` styled to match the app's surface colors and typography. 27 + 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. 28 + 5. `NativeAd.dispose()` is called when the ad scrolls far off-screen (hybrid lifecycle: create on approach, dispose on distance). 29 + 6. If an ad fails to load (`onAdFailedToLoad`), the slot collapses — no blank space, no retry for that position. 30 + 31 + **Ad-free flag:** 32 + 33 + 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. 34 + 35 + **Platform configuration:** 36 + 37 + | Platform | Requirement | 38 + | -------- | -------------------------------------------------------------------------------------------------------- | 39 + | iOS | `GADApplicationIdentifier` in `Info.plist`, `NSUserTrackingUsageDescription` for ATT, `SKAdNetworkItems` | 40 + | Android | `com.google.android.gms.ads.APPLICATION_ID` meta-data in `AndroidManifest.xml` | 41 + 42 + Package: `google_mobile_ads: ^7.0.0` 43 + 44 + ### In-App Purchases (Tips) 45 + 46 + Two consumable tip products let users support the app repeatedly. The first completed purchase of _either_ tip also flips `adsRemoved = true`, removing ads forever. 47 + 48 + **Products:** 49 + 50 + | ID | Display Name | Price | Type | 51 + | ------------ | ------------ | ----- | ---------- | 52 + | `tip_coffee` | Coffee | $1.99 | Consumable | 53 + | `tip_latte` | Latte | $4.99 | Consumable | 54 + 55 + Both are consumable so they can be purchased multiple times. 56 + 57 + **Ad removal on first purchase:** 58 + 59 + 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. 60 + 61 + 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). 62 + 63 + **Purchase flow:** 64 + 65 + 1. `InAppPurchase.instance.isAvailable()` — gate the UI if the store is unreachable. 66 + 2. `queryProductDetails({'tip_coffee', 'tip_latte'})` — fetch localized prices. 67 + 3. User taps a tip button → `buyConsumable(purchaseParam: ...)`. 68 + 4. Listen on `purchaseStream`: 69 + - `PurchaseStatus.purchased` → set `adsRemoved = true` in DB, call `completePurchase()`. 70 + - `PurchaseStatus.error` → show snackbar with `error.message`. 71 + - `PurchaseStatus.pending` → show loading indicator on the button. 72 + 5. `completePurchase()` must be called for every terminal purchase to avoid auto-refund (3-day window). 73 + 74 + Package: `in_app_purchase: ^3.2.3` 75 + 76 + ### Tip UI 77 + 78 + **Entry point:** "Support Lazurite" row in Settings screen, below theme/layout preferences. 79 + 80 + **Tip sheet** (modal bottom sheet): 81 + 82 + - Header: app icon + "Support Lazurite" 83 + - Body: two `ListTile`-style rows, each with icon (☕ / ☕☕), product name, localized price from `ProductDetails`, and a "Tip" `FilledButton`. 84 + - 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. 85 + - If `adsRemoved` is `false`: show a note below the rows: "Your first tip removes ads forever." 86 + - Loading state: skeleton tiles while `queryProductDetails` resolves. 87 + - Error state: "Store unavailable" with retry. 88 + 89 + ### Database Migration 90 + 91 + Add column to `Settings` table: 92 + 93 + ```sql 94 + ALTER TABLE settings ADD COLUMN ads_removed INTEGER NOT NULL DEFAULT 0; 95 + ``` 96 + 97 + Migration index: next sequential migration in `AppDatabase`. 98 + 99 + ### Ad Helper 100 + 101 + `AdHelper` utility class providing: 102 + 103 + - `nativeAdUnitId` — returns platform-appropriate ad unit ID (test IDs in debug, real IDs in release). 104 + - `adInterval` — `8` (posts between ads). 105 + - `profileAdOffset` — `4` (delay before first ad on profile). 106 + 107 + Test ad unit IDs (Google-provided): 108 + 109 + - iOS: `ca-app-pub-3940256099942544/3986624511` 110 + - Android: `ca-app-pub-3940256099942544/2247696110`
+95
docs/tasks/phase-7.md
··· 1 + --- 2 + title: Phase 7 Task Breakdown 3 + updated: 2026-04-04 4 + --- 5 + 6 + # Phase 7 Milestones 7 + 8 + ## M26 - Inline Native Ads 9 + 10 + ### Core - Ad Infrastructure 11 + 12 + - [ ] Add `google_mobile_ads: ^7.0.0` to `pubspec.yaml` 13 + - [ ] iOS: add `GADApplicationIdentifier`, `NSUserTrackingUsageDescription`, `SKAdNetworkItems` to `Info.plist` 14 + - [ ] Android: add `com.google.android.gms.ads.APPLICATION_ID` meta-data to `AndroidManifest.xml` 15 + - [ ] `AdHelper` utility — platform-aware ad unit IDs (test in debug, real in release), `adInterval = 8`, `profileAdOffset = 4` 16 + - [ ] Call `MobileAds.instance.initialize()` in app bootstrap, gated on `!adsRemoved` 17 + 18 + ### Core - Database 19 + 20 + - [ ] Drift migration: add `adsRemoved` (`INTEGER NOT NULL DEFAULT 0`) column to `Settings` table 21 + - [ ] `SettingsCubit` — expose `adsRemoved` flag, `setAdsRemoved(bool)` method 22 + 23 + ### Cubit 24 + 25 + - [ ] `AdCubit` — manages ad loading lifecycle per feed/profile instance 26 + - [ ] `AdState` — fields: `Map<int, NativeAd> loadedAds` (keyed by slot index), `adsRemoved` 27 + - [ ] `loadAdsForPage(int pageIndex, int postCount)` — pre-fetches `NativeAd` instances for calculated slot positions 28 + - [ ] `disposeAd(int slotIndex)` — dispose ads scrolled far off-screen 29 + - [ ] Skip all ad operations when `adsRemoved == true` 30 + 31 + ### UI - Feed Ads 32 + 33 + - [ ] `FeedLayoutView` — adjust `itemCount` to include ad slots at every `adInterval` posts 34 + - [ ] Index mapping — `visualIndex → dataIndex` translation accounting for injected ad slots 35 + - [ ] `AdPostCard` widget — wraps `AdWidget` + `NativeAd` in a card matching `PostCard` dimensions, "Sponsored" label 36 + - [ ] Linear layout: full-width ad card with muted dividers 37 + - [ ] Grid layout: ad occupies single grid cell matching card aspect ratio 38 + - [ ] Collapse slot silently on `onAdFailedToLoad` (no blank space) 39 + 40 + ### UI - Profile Ads 41 + 42 + - [ ] Profile posts tab — same ad injection with `profileAdOffset = 4` (first ad appears later) 43 + - [ ] Shared index mapping logic with feed (extract to helper or mixin) 44 + - [ ] No ads in Replies, Media, Lists, or Starter Packs tabs 45 + 46 + ### Tests 47 + 48 + - [ ] Unit tests: `AdHelper` — correct ad unit IDs per platform and build mode 49 + - [ ] Unit tests: `AdCubit` — ad loading, disposal, `adsRemoved` gating, page pre-fetch 50 + - [ ] Unit tests: index mapping — `visualIndex ↔ dataIndex` round-trip for feed and profile offsets 51 + - [ ] Widget tests: `AdPostCard` renders with "Sponsored" label, handles load failure gracefully 52 + - [ ] Widget tests: feed with ads — correct post ordering, ad at expected positions, no ads when `adsRemoved` 53 + - [ ] Widget tests: profile posts — ad offset respected, no ads in non-post tabs 54 + 55 + ## M27 - In-App Purchase Tips 56 + 57 + ### Core - Purchase Infrastructure 58 + 59 + - [ ] Add `in_app_purchase: ^3.2.3` to `pubspec.yaml` 60 + - [ ] `PurchaseRepository` — wraps `InAppPurchase.instance` 61 + - [ ] `isAvailable()` — checks store reachability 62 + - [ ] `fetchProducts()` — `queryProductDetails({'tip_coffee', 'tip_latte'})`, returns `List<ProductDetails>` 63 + - [ ] `buyTip(ProductDetails)` — calls `buyConsumable(purchaseParam: ...)` 64 + - [ ] `purchaseStream` — exposes `InAppPurchase.instance.purchaseStream` 65 + - [ ] `completePurchase(PurchaseDetails)` — forwards to `InAppPurchase.instance.completePurchase` 66 + 67 + ### Cubit 68 + 69 + - [ ] `TipCubit` — depends on `PurchaseRepository` and `SettingsCubit` 70 + - [ ] `TipState` — fields: `storeStatus` (loading/available/unavailable), `List<ProductDetails> products`, `purchaseStatus` (idle/pending/success/error), `adsRemoved` 71 + - [ ] `loadProducts()` — checks availability, fetches product details 72 + - [ ] `purchaseTip(ProductDetails)` — initiates purchase, listens for result 73 + - [ ] On `PurchaseStatus.purchased` → call `settingsCubit.setAdsRemoved(true)`, then `completePurchase()` 74 + - [ ] On `PurchaseStatus.error` → emit error state with message 75 + - [ ] Subscribe to `purchaseStream` in constructor, handle all terminal states 76 + 77 + ### UI - Tip Sheet 78 + 79 + - [ ] "Support Lazurite" row in Settings screen — opens modal bottom sheet 80 + - [ ] `TipSheet` widget — header with app icon + title 81 + - [ ] Two `ListTile` rows: Coffee (☕ $1.99) and Latte (☕☕ $4.99) with "Tip" `FilledButton` 82 + - [ ] Localized prices from `ProductDetails.price` (not hardcoded) 83 + - [ ] Loading state: skeleton tiles while products load 84 + - [ ] Error state: "Store unavailable" with retry button 85 + - [ ] If `adsRemoved`: thank-you banner above tip rows ("Ads removed — thanks for your support!") 86 + - [ ] If `!adsRemoved`: note below rows ("Your first tip removes ads forever.") 87 + - [ ] Pending state: loading indicator on tapped button, other button disabled 88 + 89 + ### Tests 90 + 91 + - [ ] Unit tests: `PurchaseRepository` — product query, buy consumable, complete purchase, availability check 92 + - [ ] Unit tests: `TipCubit` — product loading, purchase flow (success → ads removed, error → error state, pending → loading), stream subscription 93 + - [ ] Widget tests: `TipSheet` — renders products with localized prices, loading skeleton, error + retry, thank-you banner when ads removed, note when ads not removed 94 + - [ ] Widget tests: Settings screen — "Support Lazurite" row present, opens tip sheet on tap 95 + - [ ] Integration: first purchase sets `adsRemoved = true` in DB, subsequent ad cubit skips loading