···3737 <data android:scheme="lazurite" android:host="auth-complete" />
3838 </intent-filter>
3939 </activity>
4040+ <!-- Google Mobile Ads App ID -->
4141+ <meta-data
4242+ android:name="com.google.android.gms.ads.APPLICATION_ID"
4343+ android:value="ca-app-pub-3940256099942544~3347511713" />
4044 <!-- Don't delete the meta-data below.
4145 This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
4246 <meta-data
+4-4
docs/specs/phase-7.md
···2222**Ad lifecycle:**
232324241. `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.
2525+2. `AdCubit` pre-fetches deterministic ad slots for the currently loaded feed/profile page and requests slots on demand as they enter the tree.
26263. Each `NativeAd` is created with `NativeTemplateStyle(templateType: TemplateType.medium)` styled to match the app's surface colors and typography.
27274. `AdWidget` is inserted into the feed's item builder at calculated indices. The builder adjusts `itemCount` and maps visual indices back to data indices.
28285. `NativeAd.dispose()` is called when the ad scrolls far off-screen (hybrid lifecycle: create on approach, dispose on distance).
···30303131**Ad-free flag:**
32323333-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.
3333+A persisted `ads_removed` value in the Drift-backed key/value `Settings` table. When `true`, the ad item builder is skipped entirely — no `MobileAds.initialize()`, no network requests, no ad widgets.
34343535**Platform configuration:**
3636···88888989### Database Migration
90909191-Add column to `Settings` table:
9191+Seed a persisted `ads_removed` setting for existing installs:
92929393```sql
9494-ALTER TABLE settings ADD COLUMN ads_removed INTEGER NOT NULL DEFAULT 0;
9494+INSERT OR IGNORE INTO settings (key, value) VALUES ('ads_removed', 'false');
9595```
96969797Migration index: next sequential migration in `AppDatabase`.
+55-55
docs/tasks/phase-7.md
···991010### Core - Ad Infrastructure
11111212-- [ ] 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`
1212+- [x] Add `google_mobile_ads: ^7.0.0` to `pubspec.yaml`
1313+- [x] iOS: add `GADApplicationIdentifier`, `NSUserTrackingUsageDescription`, `SKAdNetworkItems` to `Info.plist`
1414+- [x] Android: add `com.google.android.gms.ads.APPLICATION_ID` meta-data to `AndroidManifest.xml`
1515+- [x] `AdHelper` utility — platform-aware ad unit IDs (test IDs in debug), `adInterval = 8`, `profileAdOffset = 4`
1616+- [x] Call `MobileAds.instance.initialize()` in app bootstrap, gated on `!adsRemoved`
17171818### Core - Database
19192020-- [ ] Drift migration: add `adsRemoved` (`INTEGER NOT NULL DEFAULT 0`) column to `Settings` table
2121-- [ ] `SettingsCubit` — expose `adsRemoved` flag, `setAdsRemoved(bool)` method
2020+- [x] Drift migration: seed persisted `ads_removed` setting for existing installs in the `Settings` table
2121+- [x] `SettingsCubit` — expose `adsRemoved` flag, `setAdsRemoved(bool)` method
22222323### Cubit
24242525-- [ ] `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`
2525+- [x] `AdCubit` — manages ad loading lifecycle per feed/profile instance
2626+- [x] `AdState` — fields: slot-indexed loaded ads map, `adsRemoved`
2727+- [x] `loadAdsForPage(int pageIndex, int postCount)` — pre-fetches ad instances for calculated slot positions
2828+- [x] `disposeAd(int slotIndex)` — dispose ads scrolled far off-screen
2929+- [x] Skip all ad operations when `adsRemoved == true`
30303131### UI - Feed Ads
32323333-- [ ] `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)
3333+- [x] `FeedLayoutView` — adjust `itemCount` to include ad slots at every `adInterval` posts
3434+- [x] Index mapping — `visualIndex → dataIndex` translation accounting for injected ad slots
3535+- [x] `AdPostCard` widget — wraps ad content in a card matching `PostCard` dimensions, "Sponsored" label
3636+- [x] Linear layout: full-width ad card with muted dividers
3737+- [x] Grid layout: ad occupies single grid cell matching card aspect ratio
3838+- [x] Collapse slot silently on ad load failure (no blank space)
39394040### UI - Profile Ads
41414242-- [ ] 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
4242+- [x] Profile posts tab — same ad injection with `profileAdOffset = 4` (first ad appears later)
4343+- [x] Shared index mapping logic with feed (extract to helper or mixin)
4444+- [x] No ads in Replies, Media, Lists, or Starter Packs tabs
45454646### Tests
47474848-- [ ] 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
4848+- [x] Unit tests: `AdHelper` — correct ad unit IDs for the active debug platform
4949+- [x] Unit tests: `AdCubit` — ad loading, disposal, `adsRemoved` gating, page pre-fetch
5050+- [x] Unit tests: index mapping — `visualIndex ↔ dataIndex` round-trip for feed and profile offsets
5151+- [x] Widget tests: `AdPostCard` renders with "Sponsored" label, slot failures collapse cleanly
5252+- [x] Widget tests: feed with ads — correct post ordering, ad at expected positions, no ads when `adsRemoved`
5353+- [x] Widget tests: profile posts — ad offset respected, no ads in non-post tabs
54545555## M27 - In-App Purchase Tips
56565757### Core - Purchase Infrastructure
58585959-- [ ] 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`
5959+- [x] Add `in_app_purchase: ^3.2.3` to `pubspec.yaml`
6060+- [x] `PurchaseRepository` — wraps `InAppPurchase.instance`
6161+- [x] `isAvailable()` — checks store reachability
6262+- [x] `fetchProducts()` — `queryProductDetails({'tip_coffee', 'tip_latte'})`, returns `List<ProductDetails>`
6363+- [x] `buyTip(ProductDetails)` — calls `buyConsumable(purchaseParam: ...)`
6464+- [x] `purchaseStream` — exposes `InAppPurchase.instance.purchaseStream`
6565+- [x] `completePurchase(PurchaseDetails)` — forwards to `InAppPurchase.instance.completePurchase`
66666767### Cubit
68686969-- [ ] `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
6969+- [x] `TipCubit` — depends on `PurchaseRepository` and `SettingsCubit`
7070+- [x] `TipState` — fields: `storeStatus` (loading/available/unavailable), `List<ProductDetails> products`, `purchaseStatus` (idle/pending/success/error), `adsRemoved`
7171+- [x] `loadProducts()` — checks availability, fetches product details
7272+- [x] `purchaseTip(ProductDetails)` — initiates purchase, listens for result
7373+- [x] On `PurchaseStatus.purchased` → call `settingsCubit.setAdsRemoved(true)`, then `completePurchase()`
7474+- [x] On `PurchaseStatus.error` → emit error state with message
7575+- [x] Subscribe to `purchaseStream` in constructor, handle all terminal states
76767777### UI - Tip Sheet
78787979-- [ ] "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
7979+- [x] "Support Lazurite" row in Settings screen — opens modal bottom sheet
8080+- [x] `TipSheet` widget — header with app icon + title
8181+- [x] Two `ListTile` rows: Coffee (☕ $1.99) and Latte (☕☕ $4.99) with "Tip" `FilledButton`
8282+- [x] Localized prices from `ProductDetails.price` (not hardcoded)
8383+- [x] Loading state: skeleton tiles while products load
8484+- [x] Error state: "Store unavailable" with retry button
8585+- [x] If `adsRemoved`: thank-you banner above tip rows ("Ads removed — thanks for your support!")
8686+- [x] If `!adsRemoved`: note below rows ("Your first tip removes ads forever.")
8787+- [x] Pending state: loading indicator on tapped button, other button disabled
88888989### Tests
90909191-- [ ] 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
9191+- [x] Unit tests: `PurchaseRepository` — product query, buy consumable, complete purchase, availability check
9292+- [x] Unit tests: `TipCubit` — product loading, purchase flow (success → ads removed, error → error state, pending → loading), stream subscription
9393+- [x] Widget tests: `TipSheet` — renders products with localized prices, loading skeleton, error + retry, thank-you banner when ads removed, note when ads not removed
9494+- [x] Widget tests: Settings screen — "Support Lazurite" row present, opens tip sheet on tap
9595+- [x] Integration: first purchase sets `adsRemoved = true` in DB, subsequent ad cubit skips loading
+11
ios/Runner/Info.plist
···7575 <string>LaunchScreen</string>
7676 <key>UIMainStoryboardFile</key>
7777 <string>Main</string>
7878+ <key>GADApplicationIdentifier</key>
7979+ <string>ca-app-pub-3940256099942544~1458002511</string>
8080+ <key>NSUserTrackingUsageDescription</key>
8181+ <string>This identifier will be used to deliver personalized ads to you.</string>
8282+ <key>SKAdNetworkItems</key>
8383+ <array>
8484+ <dict>
8585+ <key>SKAdNetworkIdentifier</key>
8686+ <string>cstr6suwn9.skadnetwork</string>
8787+ </dict>
8888+ </array>
7889 <key>NSPhotoLibraryAddUsageDescription</key>
7990 <string>Lazurite saves images and videos to your photo library when you download media.</string>
8091 <key>UISupportedInterfaceOrientations</key>
+61
lib/core/ads/ad_helper.dart
···11+import 'package:flutter/foundation.dart';
22+33+/// Static helpers for ad slot placement and index mapping.
44+class AdHelper {
55+ AdHelper._();
66+77+ /// Posts between ad slots (configurable constant).
88+ static const int adInterval = 8;
99+1010+ /// Delay before the first ad on profile posts tabs — the user came to see
1111+ /// this person's posts, so we hold off slightly longer.
1212+ static const int profileAdOffset = 4;
1313+1414+ static const String _iosTestAdUnitId = 'ca-app-pub-3940256099942544/3986624511';
1515+ static const String _androidTestAdUnitId = 'ca-app-pub-3940256099942544/2247696110';
1616+1717+ // TODO: replace with real production ad unit IDs before release.
1818+ static const String _iosReleaseAdUnitId = 'ca-app-pub-3940256099942544/3986624511';
1919+ static const String _androidReleaseAdUnitId = 'ca-app-pub-3940256099942544/2247696110';
2020+2121+ /// Returns the appropriate native ad unit ID for the current platform and
2222+ /// build mode.
2323+ static String get nativeAdUnitId {
2424+ final isIOS = defaultTargetPlatform == TargetPlatform.iOS;
2525+ if (kDebugMode) {
2626+ return isIOS ? _iosTestAdUnitId : _androidTestAdUnitId;
2727+ }
2828+ return isIOS ? _iosReleaseAdUnitId : _androidReleaseAdUnitId;
2929+ }
3030+3131+ /// Total number of visual items when ad slots are injected into [postCount]
3232+ /// posts, with the first ad deferred until [offset] posts have been shown.
3333+ ///
3434+ /// An ad slot is inserted after every [adInterval] eligible posts.
3535+ /// Posts before [offset] are not eligible.
3636+ static int visualItemCount(int postCount, {int offset = 0}) {
3737+ if (postCount == 0) return 0;
3838+ final eligible = postCount - offset;
3939+ if (eligible <= 0) return postCount;
4040+ return postCount + eligible ~/ adInterval;
4141+ }
4242+4343+ /// Maps a visual list index to the underlying post data index, or returns
4444+ /// `null` if the visual index is an ad slot.
4545+ ///
4646+ /// Uses a fixed period of `adInterval + 1` items per group (8 posts + 1 ad).
4747+ /// Posts before [offset] are returned as-is (no ad injection before offset).
4848+ static int? dataIndexForVisualIndex(int visualIndex, {int offset = 0}) {
4949+ if (visualIndex < offset) return visualIndex;
5050+ final relative = visualIndex - offset;
5151+ const period = adInterval + 1;
5252+ final posInPeriod = relative % period;
5353+ if (posInPeriod == adInterval) return null;
5454+ final fullPeriods = relative ~/ period;
5555+ return offset + fullPeriods * adInterval + posInPeriod;
5656+ }
5757+5858+ /// Returns `true` if [visualIndex] is an ad slot given the [offset].
5959+ static bool isAdSlot(int visualIndex, {int offset = 0}) =>
6060+ dataIndexForVisualIndex(visualIndex, offset: offset) == null;
6161+}
+4-1
lib/core/database/app_database.dart
···2525 static const activeAccountDidSettingKey = 'active_account_did';
26262727 @override
2828- int get schemaVersion => 14;
2828+ int get schemaVersion => 15;
29293030 @override
3131 MigrationStrategy get migration => MigrationStrategy(
···100100 WHERE key = 'feed_layout'
101101 ''');
102102 await customStatement("DELETE FROM settings WHERE key = 'feed_architecture'");
103103+ }
104104+ if (from < 15) {
105105+ await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('ads_removed', 'false')");
103106 }
104107 },
105108 );
+88
lib/features/ads/cubit/ad_cubit.dart
···11+import 'dart:async';
22+33+import 'package:flutter_bloc/flutter_bloc.dart';
44+import 'package:lazurite/core/ads/ad_helper.dart';
55+import 'package:lazurite/features/ads/cubit/ad_state.dart';
66+import 'package:lazurite/features/ads/data/native_ad_repository.dart';
77+import 'package:lazurite/features/settings/bloc/settings_cubit.dart';
88+import 'package:lazurite/features/settings/bloc/settings_state.dart';
99+1010+class AdCubit extends Cubit<AdState> {
1111+ AdCubit({required SettingsCubit settingsCubit, required NativeAdRepository nativeAdRepository})
1212+ : _nativeAdRepository = nativeAdRepository,
1313+ super(AdState(adsRemoved: settingsCubit.state.adsRemoved)) {
1414+ _sub = settingsCubit.stream.map((SettingsState s) => s.adsRemoved).distinct().listen((adsRemoved) {
1515+ if (adsRemoved) {
1616+ clearAds();
1717+ }
1818+ emit(state.copyWith(adsRemoved: adsRemoved));
1919+ });
2020+ }
2121+2222+ final NativeAdRepository _nativeAdRepository;
2323+ late final StreamSubscription<bool> _sub;
2424+ final Set<int> _loadingSlots = <int>{};
2525+2626+ Future<void> loadAdsForPage(int pageIndex, int postCount, {int offset = 0}) async {
2727+ if (state.adsRemoved || postCount <= 0) {
2828+ return;
2929+ }
3030+3131+ final visualCount = AdHelper.visualItemCount(postCount, offset: offset);
3232+ final slots = <int>[];
3333+ for (var index = 0; index < visualCount; index++) {
3434+ if (AdHelper.isAdSlot(index, offset: offset)) {
3535+ slots.add(index);
3636+ }
3737+ }
3838+3939+ for (final slotIndex in slots) {
4040+ await loadAdSlot(slotIndex);
4141+ }
4242+ }
4343+4444+ Future<void> loadAdSlot(int slotIndex) async {
4545+ if (state.adsRemoved || state.loadedAds.containsKey(slotIndex) || !_loadingSlots.add(slotIndex)) {
4646+ return;
4747+ }
4848+4949+ try {
5050+ final ad = await _nativeAdRepository.loadAd(slotIndex: slotIndex);
5151+ if (isClosed || ad == null || state.adsRemoved) {
5252+ ad?.dispose();
5353+ return;
5454+ }
5555+5656+ emit(state.copyWith(loadedAds: {...state.loadedAds, slotIndex: ad}));
5757+ } finally {
5858+ _loadingSlots.remove(slotIndex);
5959+ }
6060+ }
6161+6262+ void disposeAd(int slotIndex) {
6363+ final ad = state.loadedAds[slotIndex];
6464+ if (ad == null) {
6565+ return;
6666+ }
6767+6868+ final nextAds = Map<int, NativeAdHandle>.of(state.loadedAds)..remove(slotIndex);
6969+ ad.dispose();
7070+ emit(state.copyWith(loadedAds: nextAds));
7171+ }
7272+7373+ void clearAds() {
7474+ for (final ad in state.loadedAds.values) {
7575+ ad.dispose();
7676+ }
7777+ if (state.loadedAds.isNotEmpty) {
7878+ emit(state.copyWith(loadedAds: const {}));
7979+ }
8080+ }
8181+8282+ @override
8383+ Future<void> close() {
8484+ clearAds();
8585+ _sub.cancel();
8686+ return super.close();
8787+ }
8888+}