diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 9324fb9..5c8243d 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -37,6 +37,10 @@
+
+
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`
+- [x] `AdCubit` — manages ad loading lifecycle per feed/profile instance
+- [x] `AdState` — fields: slot-indexed loaded ads map, `adsRemoved`
+- [x] `loadAdsForPage(int pageIndex, int postCount)` — pre-fetches ad instances for calculated slot positions
+- [x] `disposeAd(int slotIndex)` — dispose ads scrolled far off-screen
+- [x] 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)
+- [x] `FeedLayoutView` — adjust `itemCount` to include ad slots at every `adInterval` posts
+- [x] Index mapping — `visualIndex → dataIndex` translation accounting for injected ad slots
+- [x] `AdPostCard` widget — wraps ad content in a card matching `PostCard` dimensions, "Sponsored" label
+- [x] Linear layout: full-width ad card with muted dividers
+- [x] Grid layout: ad occupies single grid cell matching card aspect ratio
+- [x] Collapse slot silently on ad load failure (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
+- [x] Profile posts tab — same ad injection with `profileAdOffset = 4` (first ad appears later)
+- [x] Shared index mapping logic with feed (extract to helper or mixin)
+- [x] 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
+- [x] Unit tests: `AdHelper` — correct ad unit IDs for the active debug platform
+- [x] Unit tests: `AdCubit` — ad loading, disposal, `adsRemoved` gating, page pre-fetch
+- [x] Unit tests: index mapping — `visualIndex ↔ dataIndex` round-trip for feed and profile offsets
+- [x] Widget tests: `AdPostCard` renders with "Sponsored" label, slot failures collapse cleanly
+- [x] Widget tests: feed with ads — correct post ordering, ad at expected positions, no ads when `adsRemoved`
+- [x] 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`
+- [x] Add `in_app_purchase: ^3.2.3` to `pubspec.yaml`
+- [x] `PurchaseRepository` — wraps `InAppPurchase.instance`
+- [x] `isAvailable()` — checks store reachability
+- [x] `fetchProducts()` — `queryProductDetails({'tip_coffee', 'tip_latte'})`, returns `List`
+- [x] `buyTip(ProductDetails)` — calls `buyConsumable(purchaseParam: ...)`
+- [x] `purchaseStream` — exposes `InAppPurchase.instance.purchaseStream`
+- [x] `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
+- [x] `TipCubit` — depends on `PurchaseRepository` and `SettingsCubit`
+- [x] `TipState` — fields: `storeStatus` (loading/available/unavailable), `List products`, `purchaseStatus` (idle/pending/success/error), `adsRemoved`
+- [x] `loadProducts()` — checks availability, fetches product details
+- [x] `purchaseTip(ProductDetails)` — initiates purchase, listens for result
+- [x] On `PurchaseStatus.purchased` → call `settingsCubit.setAdsRemoved(true)`, then `completePurchase()`
+- [x] On `PurchaseStatus.error` → emit error state with message
+- [x] 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
+- [x] "Support Lazurite" row in Settings screen — opens modal bottom sheet
+- [x] `TipSheet` widget — header with app icon + title
+- [x] Two `ListTile` rows: Coffee (☕ $1.99) and Latte (☕☕ $4.99) with "Tip" `FilledButton`
+- [x] Localized prices from `ProductDetails.price` (not hardcoded)
+- [x] Loading state: skeleton tiles while products load
+- [x] Error state: "Store unavailable" with retry button
+- [x] If `adsRemoved`: thank-you banner above tip rows ("Ads removed — thanks for your support!")
+- [x] If `!adsRemoved`: note below rows ("Your first tip removes ads forever.")
+- [x] 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
+- [x] Unit tests: `PurchaseRepository` — product query, buy consumable, complete purchase, availability check
+- [x] Unit tests: `TipCubit` — product loading, purchase flow (success → ads removed, error → error state, pending → loading), stream subscription
+- [x] Widget tests: `TipSheet` — renders products with localized prices, loading skeleton, error + retry, thank-you banner when ads removed, note when ads not removed
+- [x] Widget tests: Settings screen — "Support Lazurite" row present, opens tip sheet on tap
+- [x] Integration: first purchase sets `adsRemoved = true` in DB, subsequent ad cubit skips loading
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 24bfae7..5dc9f9f 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -75,6 +75,17 @@
LaunchScreen
UIMainStoryboardFile
Main
+ GADApplicationIdentifier
+ ca-app-pub-3940256099942544~1458002511
+ NSUserTrackingUsageDescription
+ This identifier will be used to deliver personalized ads to you.
+ SKAdNetworkItems
+
+
+ SKAdNetworkIdentifier
+ cstr6suwn9.skadnetwork
+
+
NSPhotoLibraryAddUsageDescription
Lazurite saves images and videos to your photo library when you download media.
UISupportedInterfaceOrientations
diff --git a/lib/core/ads/ad_helper.dart b/lib/core/ads/ad_helper.dart
new file mode 100644
index 0000000..92df79c
--- /dev/null
+++ b/lib/core/ads/ad_helper.dart
@@ -0,0 +1,61 @@
+import 'package:flutter/foundation.dart';
+
+/// Static helpers for ad slot placement and index mapping.
+class AdHelper {
+ AdHelper._();
+
+ /// Posts between ad slots (configurable constant).
+ static const int adInterval = 8;
+
+ /// Delay before the first ad on profile posts tabs — the user came to see
+ /// this person's posts, so we hold off slightly longer.
+ static const int profileAdOffset = 4;
+
+ static const String _iosTestAdUnitId = 'ca-app-pub-3940256099942544/3986624511';
+ static const String _androidTestAdUnitId = 'ca-app-pub-3940256099942544/2247696110';
+
+ // TODO: replace with real production ad unit IDs before release.
+ static const String _iosReleaseAdUnitId = 'ca-app-pub-3940256099942544/3986624511';
+ static const String _androidReleaseAdUnitId = 'ca-app-pub-3940256099942544/2247696110';
+
+ /// Returns the appropriate native ad unit ID for the current platform and
+ /// build mode.
+ static String get nativeAdUnitId {
+ final isIOS = defaultTargetPlatform == TargetPlatform.iOS;
+ if (kDebugMode) {
+ return isIOS ? _iosTestAdUnitId : _androidTestAdUnitId;
+ }
+ return isIOS ? _iosReleaseAdUnitId : _androidReleaseAdUnitId;
+ }
+
+ /// Total number of visual items when ad slots are injected into [postCount]
+ /// posts, with the first ad deferred until [offset] posts have been shown.
+ ///
+ /// An ad slot is inserted after every [adInterval] eligible posts.
+ /// Posts before [offset] are not eligible.
+ static int visualItemCount(int postCount, {int offset = 0}) {
+ if (postCount == 0) return 0;
+ final eligible = postCount - offset;
+ if (eligible <= 0) return postCount;
+ return postCount + eligible ~/ adInterval;
+ }
+
+ /// Maps a visual list index to the underlying post data index, or returns
+ /// `null` if the visual index is an ad slot.
+ ///
+ /// Uses a fixed period of `adInterval + 1` items per group (8 posts + 1 ad).
+ /// Posts before [offset] are returned as-is (no ad injection before offset).
+ static int? dataIndexForVisualIndex(int visualIndex, {int offset = 0}) {
+ if (visualIndex < offset) return visualIndex;
+ final relative = visualIndex - offset;
+ const period = adInterval + 1;
+ final posInPeriod = relative % period;
+ if (posInPeriod == adInterval) return null;
+ final fullPeriods = relative ~/ period;
+ return offset + fullPeriods * adInterval + posInPeriod;
+ }
+
+ /// Returns `true` if [visualIndex] is an ad slot given the [offset].
+ static bool isAdSlot(int visualIndex, {int offset = 0}) =>
+ dataIndexForVisualIndex(visualIndex, offset: offset) == null;
+}
diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart
index 1489d2a..c8649a2 100644
--- a/lib/core/database/app_database.dart
+++ b/lib/core/database/app_database.dart
@@ -25,7 +25,7 @@ class AppDatabase extends _$AppDatabase {
static const activeAccountDidSettingKey = 'active_account_did';
@override
- int get schemaVersion => 14;
+ int get schemaVersion => 15;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -101,6 +101,9 @@ class AppDatabase extends _$AppDatabase {
''');
await customStatement("DELETE FROM settings WHERE key = 'feed_architecture'");
}
+ if (from < 15) {
+ await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('ads_removed', 'false')");
+ }
},
);
diff --git a/lib/features/ads/cubit/ad_cubit.dart b/lib/features/ads/cubit/ad_cubit.dart
new file mode 100644
index 0000000..6f79e1a
--- /dev/null
+++ b/lib/features/ads/cubit/ad_cubit.dart
@@ -0,0 +1,88 @@
+import 'dart:async';
+
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:lazurite/core/ads/ad_helper.dart';
+import 'package:lazurite/features/ads/cubit/ad_state.dart';
+import 'package:lazurite/features/ads/data/native_ad_repository.dart';
+import 'package:lazurite/features/settings/bloc/settings_cubit.dart';
+import 'package:lazurite/features/settings/bloc/settings_state.dart';
+
+class AdCubit extends Cubit {
+ AdCubit({required SettingsCubit settingsCubit, required NativeAdRepository nativeAdRepository})
+ : _nativeAdRepository = nativeAdRepository,
+ super(AdState(adsRemoved: settingsCubit.state.adsRemoved)) {
+ _sub = settingsCubit.stream.map((SettingsState s) => s.adsRemoved).distinct().listen((adsRemoved) {
+ if (adsRemoved) {
+ clearAds();
+ }
+ emit(state.copyWith(adsRemoved: adsRemoved));
+ });
+ }
+
+ final NativeAdRepository _nativeAdRepository;
+ late final StreamSubscription _sub;
+ final Set _loadingSlots = {};
+
+ Future loadAdsForPage(int pageIndex, int postCount, {int offset = 0}) async {
+ if (state.adsRemoved || postCount <= 0) {
+ return;
+ }
+
+ final visualCount = AdHelper.visualItemCount(postCount, offset: offset);
+ final slots = [];
+ for (var index = 0; index < visualCount; index++) {
+ if (AdHelper.isAdSlot(index, offset: offset)) {
+ slots.add(index);
+ }
+ }
+
+ for (final slotIndex in slots) {
+ await loadAdSlot(slotIndex);
+ }
+ }
+
+ Future loadAdSlot(int slotIndex) async {
+ if (state.adsRemoved || state.loadedAds.containsKey(slotIndex) || !_loadingSlots.add(slotIndex)) {
+ return;
+ }
+
+ try {
+ final ad = await _nativeAdRepository.loadAd(slotIndex: slotIndex);
+ if (isClosed || ad == null || state.adsRemoved) {
+ ad?.dispose();
+ return;
+ }
+
+ emit(state.copyWith(loadedAds: {...state.loadedAds, slotIndex: ad}));
+ } finally {
+ _loadingSlots.remove(slotIndex);
+ }
+ }
+
+ void disposeAd(int slotIndex) {
+ final ad = state.loadedAds[slotIndex];
+ if (ad == null) {
+ return;
+ }
+
+ final nextAds = Map.of(state.loadedAds)..remove(slotIndex);
+ ad.dispose();
+ emit(state.copyWith(loadedAds: nextAds));
+ }
+
+ void clearAds() {
+ for (final ad in state.loadedAds.values) {
+ ad.dispose();
+ }
+ if (state.loadedAds.isNotEmpty) {
+ emit(state.copyWith(loadedAds: const {}));
+ }
+ }
+
+ @override
+ Future close() {
+ clearAds();
+ _sub.cancel();
+ return super.close();
+ }
+}
diff --git a/lib/features/ads/cubit/ad_state.dart b/lib/features/ads/cubit/ad_state.dart
new file mode 100644
index 0000000..84c544d
--- /dev/null
+++ b/lib/features/ads/cubit/ad_state.dart
@@ -0,0 +1,16 @@
+import 'package:equatable/equatable.dart';
+import 'package:lazurite/features/ads/data/native_ad_repository.dart';
+
+class AdState extends Equatable {
+ const AdState({this.loadedAds = const {}, this.adsRemoved = false});
+
+ final Map loadedAds;
+ final bool adsRemoved;
+
+ AdState copyWith({Map? loadedAds, bool? adsRemoved}) {
+ return AdState(loadedAds: loadedAds ?? this.loadedAds, adsRemoved: adsRemoved ?? this.adsRemoved);
+ }
+
+ @override
+ List