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.

feat: monetization (inline + iap)

+1994 -134
+4
android/app/src/main/AndroidManifest.xml
··· 37 37 <data android:scheme="lazurite" android:host="auth-complete" /> 38 38 </intent-filter> 39 39 </activity> 40 + <!-- Google Mobile Ads App ID --> 41 + <meta-data 42 + android:name="com.google.android.gms.ads.APPLICATION_ID" 43 + android:value="ca-app-pub-3940256099942544~3347511713" /> 40 44 <!-- Don't delete the meta-data below. 41 45 This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> 42 46 <meta-data
+4 -4
docs/specs/phase-7.md
··· 22 22 **Ad lifecycle:** 23 23 24 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. 25 + 2. `AdCubit` pre-fetches deterministic ad slots for the currently loaded feed/profile page and requests slots on demand as they enter the tree. 26 26 3. Each `NativeAd` is created with `NativeTemplateStyle(templateType: TemplateType.medium)` styled to match the app's surface colors and typography. 27 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 28 5. `NativeAd.dispose()` is called when the ad scrolls far off-screen (hybrid lifecycle: create on approach, dispose on distance). ··· 30 30 31 31 **Ad-free flag:** 32 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. 33 + 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. 34 34 35 35 **Platform configuration:** 36 36 ··· 88 88 89 89 ### Database Migration 90 90 91 - Add column to `Settings` table: 91 + Seed a persisted `ads_removed` setting for existing installs: 92 92 93 93 ```sql 94 - ALTER TABLE settings ADD COLUMN ads_removed INTEGER NOT NULL DEFAULT 0; 94 + INSERT OR IGNORE INTO settings (key, value) VALUES ('ads_removed', 'false'); 95 95 ``` 96 96 97 97 Migration index: next sequential migration in `AppDatabase`.
+55 -55
docs/tasks/phase-7.md
··· 9 9 10 10 ### Core - Ad Infrastructure 11 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` 12 + - [x] Add `google_mobile_ads: ^7.0.0` to `pubspec.yaml` 13 + - [x] iOS: add `GADApplicationIdentifier`, `NSUserTrackingUsageDescription`, `SKAdNetworkItems` to `Info.plist` 14 + - [x] Android: add `com.google.android.gms.ads.APPLICATION_ID` meta-data to `AndroidManifest.xml` 15 + - [x] `AdHelper` utility — platform-aware ad unit IDs (test IDs in debug), `adInterval = 8`, `profileAdOffset = 4` 16 + - [x] Call `MobileAds.instance.initialize()` in app bootstrap, gated on `!adsRemoved` 17 17 18 18 ### Core - Database 19 19 20 - - [ ] Drift migration: add `adsRemoved` (`INTEGER NOT NULL DEFAULT 0`) column to `Settings` table 21 - - [ ] `SettingsCubit` — expose `adsRemoved` flag, `setAdsRemoved(bool)` method 20 + - [x] Drift migration: seed persisted `ads_removed` setting for existing installs in the `Settings` table 21 + - [x] `SettingsCubit` — expose `adsRemoved` flag, `setAdsRemoved(bool)` method 22 22 23 23 ### Cubit 24 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` 25 + - [x] `AdCubit` — manages ad loading lifecycle per feed/profile instance 26 + - [x] `AdState` — fields: slot-indexed loaded ads map, `adsRemoved` 27 + - [x] `loadAdsForPage(int pageIndex, int postCount)` — pre-fetches ad instances for calculated slot positions 28 + - [x] `disposeAd(int slotIndex)` — dispose ads scrolled far off-screen 29 + - [x] Skip all ad operations when `adsRemoved == true` 30 30 31 31 ### UI - Feed Ads 32 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) 33 + - [x] `FeedLayoutView` — adjust `itemCount` to include ad slots at every `adInterval` posts 34 + - [x] Index mapping — `visualIndex → dataIndex` translation accounting for injected ad slots 35 + - [x] `AdPostCard` widget — wraps ad content in a card matching `PostCard` dimensions, "Sponsored" label 36 + - [x] Linear layout: full-width ad card with muted dividers 37 + - [x] Grid layout: ad occupies single grid cell matching card aspect ratio 38 + - [x] Collapse slot silently on ad load failure (no blank space) 39 39 40 40 ### UI - Profile Ads 41 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 42 + - [x] Profile posts tab — same ad injection with `profileAdOffset = 4` (first ad appears later) 43 + - [x] Shared index mapping logic with feed (extract to helper or mixin) 44 + - [x] No ads in Replies, Media, Lists, or Starter Packs tabs 45 45 46 46 ### Tests 47 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 48 + - [x] Unit tests: `AdHelper` — correct ad unit IDs for the active debug platform 49 + - [x] Unit tests: `AdCubit` — ad loading, disposal, `adsRemoved` gating, page pre-fetch 50 + - [x] Unit tests: index mapping — `visualIndex ↔ dataIndex` round-trip for feed and profile offsets 51 + - [x] Widget tests: `AdPostCard` renders with "Sponsored" label, slot failures collapse cleanly 52 + - [x] Widget tests: feed with ads — correct post ordering, ad at expected positions, no ads when `adsRemoved` 53 + - [x] Widget tests: profile posts — ad offset respected, no ads in non-post tabs 54 54 55 55 ## M27 - In-App Purchase Tips 56 56 57 57 ### Core - Purchase Infrastructure 58 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` 59 + - [x] Add `in_app_purchase: ^3.2.3` to `pubspec.yaml` 60 + - [x] `PurchaseRepository` — wraps `InAppPurchase.instance` 61 + - [x] `isAvailable()` — checks store reachability 62 + - [x] `fetchProducts()` — `queryProductDetails({'tip_coffee', 'tip_latte'})`, returns `List<ProductDetails>` 63 + - [x] `buyTip(ProductDetails)` — calls `buyConsumable(purchaseParam: ...)` 64 + - [x] `purchaseStream` — exposes `InAppPurchase.instance.purchaseStream` 65 + - [x] `completePurchase(PurchaseDetails)` — forwards to `InAppPurchase.instance.completePurchase` 66 66 67 67 ### Cubit 68 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 69 + - [x] `TipCubit` — depends on `PurchaseRepository` and `SettingsCubit` 70 + - [x] `TipState` — fields: `storeStatus` (loading/available/unavailable), `List<ProductDetails> products`, `purchaseStatus` (idle/pending/success/error), `adsRemoved` 71 + - [x] `loadProducts()` — checks availability, fetches product details 72 + - [x] `purchaseTip(ProductDetails)` — initiates purchase, listens for result 73 + - [x] On `PurchaseStatus.purchased` → call `settingsCubit.setAdsRemoved(true)`, then `completePurchase()` 74 + - [x] On `PurchaseStatus.error` → emit error state with message 75 + - [x] Subscribe to `purchaseStream` in constructor, handle all terminal states 76 76 77 77 ### UI - Tip Sheet 78 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 79 + - [x] "Support Lazurite" row in Settings screen — opens modal bottom sheet 80 + - [x] `TipSheet` widget — header with app icon + title 81 + - [x] Two `ListTile` rows: Coffee (☕ $1.99) and Latte (☕☕ $4.99) with "Tip" `FilledButton` 82 + - [x] Localized prices from `ProductDetails.price` (not hardcoded) 83 + - [x] Loading state: skeleton tiles while products load 84 + - [x] Error state: "Store unavailable" with retry button 85 + - [x] If `adsRemoved`: thank-you banner above tip rows ("Ads removed — thanks for your support!") 86 + - [x] If `!adsRemoved`: note below rows ("Your first tip removes ads forever.") 87 + - [x] Pending state: loading indicator on tapped button, other button disabled 88 88 89 89 ### Tests 90 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 91 + - [x] Unit tests: `PurchaseRepository` — product query, buy consumable, complete purchase, availability check 92 + - [x] Unit tests: `TipCubit` — product loading, purchase flow (success → ads removed, error → error state, pending → loading), stream subscription 93 + - [x] Widget tests: `TipSheet` — renders products with localized prices, loading skeleton, error + retry, thank-you banner when ads removed, note when ads not removed 94 + - [x] Widget tests: Settings screen — "Support Lazurite" row present, opens tip sheet on tap 95 + - [x] Integration: first purchase sets `adsRemoved = true` in DB, subsequent ad cubit skips loading
+11
ios/Runner/Info.plist
··· 75 75 <string>LaunchScreen</string> 76 76 <key>UIMainStoryboardFile</key> 77 77 <string>Main</string> 78 + <key>GADApplicationIdentifier</key> 79 + <string>ca-app-pub-3940256099942544~1458002511</string> 80 + <key>NSUserTrackingUsageDescription</key> 81 + <string>This identifier will be used to deliver personalized ads to you.</string> 82 + <key>SKAdNetworkItems</key> 83 + <array> 84 + <dict> 85 + <key>SKAdNetworkIdentifier</key> 86 + <string>cstr6suwn9.skadnetwork</string> 87 + </dict> 88 + </array> 78 89 <key>NSPhotoLibraryAddUsageDescription</key> 79 90 <string>Lazurite saves images and videos to your photo library when you download media.</string> 80 91 <key>UISupportedInterfaceOrientations</key>
+61
lib/core/ads/ad_helper.dart
··· 1 + import 'package:flutter/foundation.dart'; 2 + 3 + /// Static helpers for ad slot placement and index mapping. 4 + class AdHelper { 5 + AdHelper._(); 6 + 7 + /// Posts between ad slots (configurable constant). 8 + static const int adInterval = 8; 9 + 10 + /// Delay before the first ad on profile posts tabs — the user came to see 11 + /// this person's posts, so we hold off slightly longer. 12 + static const int profileAdOffset = 4; 13 + 14 + static const String _iosTestAdUnitId = 'ca-app-pub-3940256099942544/3986624511'; 15 + static const String _androidTestAdUnitId = 'ca-app-pub-3940256099942544/2247696110'; 16 + 17 + // TODO: replace with real production ad unit IDs before release. 18 + static const String _iosReleaseAdUnitId = 'ca-app-pub-3940256099942544/3986624511'; 19 + static const String _androidReleaseAdUnitId = 'ca-app-pub-3940256099942544/2247696110'; 20 + 21 + /// Returns the appropriate native ad unit ID for the current platform and 22 + /// build mode. 23 + static String get nativeAdUnitId { 24 + final isIOS = defaultTargetPlatform == TargetPlatform.iOS; 25 + if (kDebugMode) { 26 + return isIOS ? _iosTestAdUnitId : _androidTestAdUnitId; 27 + } 28 + return isIOS ? _iosReleaseAdUnitId : _androidReleaseAdUnitId; 29 + } 30 + 31 + /// Total number of visual items when ad slots are injected into [postCount] 32 + /// posts, with the first ad deferred until [offset] posts have been shown. 33 + /// 34 + /// An ad slot is inserted after every [adInterval] eligible posts. 35 + /// Posts before [offset] are not eligible. 36 + static int visualItemCount(int postCount, {int offset = 0}) { 37 + if (postCount == 0) return 0; 38 + final eligible = postCount - offset; 39 + if (eligible <= 0) return postCount; 40 + return postCount + eligible ~/ adInterval; 41 + } 42 + 43 + /// Maps a visual list index to the underlying post data index, or returns 44 + /// `null` if the visual index is an ad slot. 45 + /// 46 + /// Uses a fixed period of `adInterval + 1` items per group (8 posts + 1 ad). 47 + /// Posts before [offset] are returned as-is (no ad injection before offset). 48 + static int? dataIndexForVisualIndex(int visualIndex, {int offset = 0}) { 49 + if (visualIndex < offset) return visualIndex; 50 + final relative = visualIndex - offset; 51 + const period = adInterval + 1; 52 + final posInPeriod = relative % period; 53 + if (posInPeriod == adInterval) return null; 54 + final fullPeriods = relative ~/ period; 55 + return offset + fullPeriods * adInterval + posInPeriod; 56 + } 57 + 58 + /// Returns `true` if [visualIndex] is an ad slot given the [offset]. 59 + static bool isAdSlot(int visualIndex, {int offset = 0}) => 60 + dataIndexForVisualIndex(visualIndex, offset: offset) == null; 61 + }
+4 -1
lib/core/database/app_database.dart
··· 25 25 static const activeAccountDidSettingKey = 'active_account_did'; 26 26 27 27 @override 28 - int get schemaVersion => 14; 28 + int get schemaVersion => 15; 29 29 30 30 @override 31 31 MigrationStrategy get migration => MigrationStrategy( ··· 100 100 WHERE key = 'feed_layout' 101 101 '''); 102 102 await customStatement("DELETE FROM settings WHERE key = 'feed_architecture'"); 103 + } 104 + if (from < 15) { 105 + await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('ads_removed', 'false')"); 103 106 } 104 107 }, 105 108 );
+88
lib/features/ads/cubit/ad_cubit.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/core/ads/ad_helper.dart'; 5 + import 'package:lazurite/features/ads/cubit/ad_state.dart'; 6 + import 'package:lazurite/features/ads/data/native_ad_repository.dart'; 7 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 8 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 9 + 10 + class AdCubit extends Cubit<AdState> { 11 + AdCubit({required SettingsCubit settingsCubit, required NativeAdRepository nativeAdRepository}) 12 + : _nativeAdRepository = nativeAdRepository, 13 + super(AdState(adsRemoved: settingsCubit.state.adsRemoved)) { 14 + _sub = settingsCubit.stream.map((SettingsState s) => s.adsRemoved).distinct().listen((adsRemoved) { 15 + if (adsRemoved) { 16 + clearAds(); 17 + } 18 + emit(state.copyWith(adsRemoved: adsRemoved)); 19 + }); 20 + } 21 + 22 + final NativeAdRepository _nativeAdRepository; 23 + late final StreamSubscription<bool> _sub; 24 + final Set<int> _loadingSlots = <int>{}; 25 + 26 + Future<void> loadAdsForPage(int pageIndex, int postCount, {int offset = 0}) async { 27 + if (state.adsRemoved || postCount <= 0) { 28 + return; 29 + } 30 + 31 + final visualCount = AdHelper.visualItemCount(postCount, offset: offset); 32 + final slots = <int>[]; 33 + for (var index = 0; index < visualCount; index++) { 34 + if (AdHelper.isAdSlot(index, offset: offset)) { 35 + slots.add(index); 36 + } 37 + } 38 + 39 + for (final slotIndex in slots) { 40 + await loadAdSlot(slotIndex); 41 + } 42 + } 43 + 44 + Future<void> loadAdSlot(int slotIndex) async { 45 + if (state.adsRemoved || state.loadedAds.containsKey(slotIndex) || !_loadingSlots.add(slotIndex)) { 46 + return; 47 + } 48 + 49 + try { 50 + final ad = await _nativeAdRepository.loadAd(slotIndex: slotIndex); 51 + if (isClosed || ad == null || state.adsRemoved) { 52 + ad?.dispose(); 53 + return; 54 + } 55 + 56 + emit(state.copyWith(loadedAds: {...state.loadedAds, slotIndex: ad})); 57 + } finally { 58 + _loadingSlots.remove(slotIndex); 59 + } 60 + } 61 + 62 + void disposeAd(int slotIndex) { 63 + final ad = state.loadedAds[slotIndex]; 64 + if (ad == null) { 65 + return; 66 + } 67 + 68 + final nextAds = Map<int, NativeAdHandle>.of(state.loadedAds)..remove(slotIndex); 69 + ad.dispose(); 70 + emit(state.copyWith(loadedAds: nextAds)); 71 + } 72 + 73 + void clearAds() { 74 + for (final ad in state.loadedAds.values) { 75 + ad.dispose(); 76 + } 77 + if (state.loadedAds.isNotEmpty) { 78 + emit(state.copyWith(loadedAds: const {})); 79 + } 80 + } 81 + 82 + @override 83 + Future<void> close() { 84 + clearAds(); 85 + _sub.cancel(); 86 + return super.close(); 87 + } 88 + }
+16
lib/features/ads/cubit/ad_state.dart
··· 1 + import 'package:equatable/equatable.dart'; 2 + import 'package:lazurite/features/ads/data/native_ad_repository.dart'; 3 + 4 + class AdState extends Equatable { 5 + const AdState({this.loadedAds = const {}, this.adsRemoved = false}); 6 + 7 + final Map<int, NativeAdHandle> loadedAds; 8 + final bool adsRemoved; 9 + 10 + AdState copyWith({Map<int, NativeAdHandle>? loadedAds, bool? adsRemoved}) { 11 + return AdState(loadedAds: loadedAds ?? this.loadedAds, adsRemoved: adsRemoved ?? this.adsRemoved); 12 + } 13 + 14 + @override 15 + List<Object?> get props => [loadedAds, adsRemoved]; 16 + }
+67
lib/features/ads/data/native_ad_repository.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter/widgets.dart'; 4 + import 'package:google_mobile_ads/google_mobile_ads.dart'; 5 + import 'package:lazurite/core/ads/ad_helper.dart'; 6 + import 'package:lazurite/core/logging/app_logger.dart'; 7 + 8 + abstract class NativeAdHandle { 9 + Widget buildWidget(); 10 + 11 + void dispose(); 12 + } 13 + 14 + abstract class NativeAdRepository { 15 + Future<NativeAdHandle?> loadAd({required int slotIndex}); 16 + } 17 + 18 + class GoogleMobileNativeAdRepository implements NativeAdRepository { 19 + @override 20 + Future<NativeAdHandle?> loadAd({required int slotIndex}) async { 21 + final completer = Completer<NativeAdHandle?>(); 22 + 23 + try { 24 + late final NativeAd ad; 25 + ad = NativeAd( 26 + adUnitId: AdHelper.nativeAdUnitId, 27 + request: const AdRequest(), 28 + nativeTemplateStyle: NativeTemplateStyle(templateType: TemplateType.medium), 29 + listener: NativeAdListener( 30 + onAdLoaded: (_) { 31 + if (!completer.isCompleted) { 32 + completer.complete(_GoogleMobileNativeAdHandle(ad)); 33 + } 34 + }, 35 + onAdFailedToLoad: (failedAd, error) { 36 + failedAd.dispose(); 37 + log.d('Native ad failed to load for slot $slotIndex: $error'); 38 + if (!completer.isCompleted) { 39 + completer.complete(null); 40 + } 41 + }, 42 + ), 43 + ); 44 + 45 + await ad.load(); 46 + return completer.future; 47 + } catch (error, stackTrace) { 48 + log.e('Failed to request native ad for slot $slotIndex', error: error, stackTrace: stackTrace); 49 + if (!completer.isCompleted) { 50 + completer.complete(null); 51 + } 52 + return completer.future; 53 + } 54 + } 55 + } 56 + 57 + class _GoogleMobileNativeAdHandle implements NativeAdHandle { 58 + _GoogleMobileNativeAdHandle(this._ad); 59 + 60 + final NativeAd _ad; 61 + 62 + @override 63 + Widget buildWidget() => AdWidget(ad: _ad); 64 + 65 + @override 66 + void dispose() => _ad.dispose(); 67 + }
+62
lib/features/ads/presentation/ad_post_card.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + class AdPostCard extends StatelessWidget { 4 + const AdPostCard({required this.child, this.isLinear = false, super.key}); 5 + 6 + final bool isLinear; 7 + final Widget child; 8 + 9 + @override 10 + Widget build(BuildContext context) { 11 + return isLinear ? _buildLinear(context) : _buildGrid(context); 12 + } 13 + 14 + Widget _buildLinear(BuildContext context) { 15 + final theme = Theme.of(context); 16 + return Column( 17 + mainAxisSize: MainAxisSize.min, 18 + children: [ 19 + const Divider(height: 1), 20 + Padding( 21 + padding: const EdgeInsets.fromLTRB(16, 6, 16, 0), 22 + child: Align( 23 + alignment: Alignment.centerLeft, 24 + child: Text( 25 + 'Sponsored', 26 + style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 27 + ), 28 + ), 29 + ), 30 + ConstrainedBox( 31 + constraints: const BoxConstraints(minWidth: double.infinity, minHeight: 80, maxHeight: 200), 32 + child: child, 33 + ), 34 + const Divider(height: 1), 35 + ], 36 + ); 37 + } 38 + 39 + Widget _buildGrid(BuildContext context) { 40 + final theme = Theme.of(context); 41 + return Stack( 42 + children: [ 43 + Positioned.fill(child: child), 44 + Positioned( 45 + top: 4, 46 + right: 4, 47 + child: Container( 48 + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), 49 + decoration: BoxDecoration( 50 + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.85), 51 + borderRadius: BorderRadius.circular(4), 52 + ), 53 + child: Text( 54 + 'Sponsored', 55 + style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 56 + ), 57 + ), 58 + ), 59 + ], 60 + ); 61 + } 62 + }
+70
lib/features/ads/presentation/ad_slot.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:lazurite/features/ads/cubit/ad_cubit.dart'; 6 + import 'package:lazurite/features/ads/cubit/ad_state.dart'; 7 + import 'package:lazurite/features/ads/presentation/ad_post_card.dart'; 8 + 9 + class AdSlot extends StatefulWidget { 10 + const AdSlot({required this.slotIndex, this.isLinear = false, super.key}); 11 + 12 + final int slotIndex; 13 + final bool isLinear; 14 + 15 + @override 16 + State<AdSlot> createState() => _AdSlotState(); 17 + } 18 + 19 + class _AdSlotState extends State<AdSlot> { 20 + late final AdCubit _adCubit; 21 + 22 + @override 23 + void initState() { 24 + super.initState(); 25 + _adCubit = context.read<AdCubit>(); 26 + _requestAd(); 27 + } 28 + 29 + @override 30 + void didUpdateWidget(covariant AdSlot oldWidget) { 31 + super.didUpdateWidget(oldWidget); 32 + if (oldWidget.slotIndex != widget.slotIndex) { 33 + _requestAd(); 34 + } 35 + } 36 + 37 + @override 38 + void dispose() { 39 + _adCubit.disposeAd(widget.slotIndex); 40 + super.dispose(); 41 + } 42 + 43 + void _requestAd() { 44 + if (_adCubit.state.adsRemoved) { 45 + return; 46 + } 47 + unawaited(_adCubit.loadAdSlot(widget.slotIndex)); 48 + } 49 + 50 + @override 51 + Widget build(BuildContext context) { 52 + return BlocBuilder<AdCubit, AdState>( 53 + buildWhen: (previous, current) => 54 + previous.adsRemoved != current.adsRemoved || 55 + previous.loadedAds[widget.slotIndex] != current.loadedAds[widget.slotIndex], 56 + builder: (context, state) { 57 + if (state.adsRemoved) { 58 + return const SizedBox.shrink(); 59 + } 60 + 61 + final ad = state.loadedAds[widget.slotIndex]; 62 + if (ad == null) { 63 + return const SizedBox.shrink(); 64 + } 65 + 66 + return AdPostCard(isLinear: widget.isLinear, child: ad.buildWidget()); 67 + }, 68 + ); 69 + } 70 + }
+34 -7
lib/features/feed/presentation/home_feed_screen.dart
··· 5 5 import 'package:flutter_bloc/flutter_bloc.dart'; 6 6 import 'package:go_router/go_router.dart'; 7 7 import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; 8 + import 'package:lazurite/features/ads/cubit/ad_cubit.dart'; 9 + import 'package:lazurite/features/ads/data/native_ad_repository.dart'; 8 10 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 9 11 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 10 12 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; ··· 12 14 import 'package:lazurite/features/feed/data/feed_repository.dart'; 13 15 import 'package:lazurite/features/feed/presentation/widgets/feed_layout_view.dart'; 14 16 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 17 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 15 18 16 19 /// Returns the number of grid columns for [width] per the responsive 17 20 /// breakpoints defined in the UI spec. ··· 237 240 238 241 class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveClientMixin { 239 242 final List<FeedViewPost> _posts = []; 243 + late final AdCubit _adCubit; 240 244 String? _cursor; 241 245 bool _isLoading = false; 242 246 bool _showInitialLoading = false; ··· 251 255 @override 252 256 void initState() { 253 257 super.initState(); 258 + _adCubit = AdCubit( 259 + settingsCubit: context.read<SettingsCubit>(), 260 + nativeAdRepository: context.read<NativeAdRepository>(), 261 + ); 254 262 _scrollController.addListener(_onScroll); 255 263 _primeFeed(); 256 264 } ··· 259 267 void dispose() { 260 268 _scrollController.removeListener(_onScroll); 261 269 _scrollController.dispose(); 270 + _adCubit.close(); 262 271 super.dispose(); 263 272 } 264 273 ··· 323 332 _showInitialLoading = false; 324 333 _hasError = false; 325 334 }); 335 + _prefetchAds(); 326 336 } catch (e) { 327 337 if (_posts.isNotEmpty) { 328 338 _setStateIfMounted(() { ··· 362 372 _cursor = result.cursor; 363 373 _isLoadingMore = false; 364 374 }); 375 + _prefetchAds(); 365 376 } catch (e) { 366 377 _setStateIfMounted(() => _isLoadingMore = false); 367 378 } ··· 375 386 setState(fn); 376 387 } 377 388 389 + void _prefetchAds() { 390 + if (_posts.isEmpty || !mounted) { 391 + return; 392 + } 393 + 394 + WidgetsBinding.instance.addPostFrameCallback((_) { 395 + if (!mounted) { 396 + return; 397 + } 398 + _adCubit.loadAdsForPage(0, _posts.length); 399 + }); 400 + } 401 + 378 402 Future<FeedResult> _fetchFeed(FeedRepository repo, {String? cursor}) async { 379 403 final feedType = widget.feed.type; 380 404 if (feedType is SavedFeedTypeKnownValue) { ··· 433 457 ); 434 458 } 435 459 436 - return FeedLayoutView( 437 - itemCount: _posts.length, 438 - scrollController: _scrollController, 439 - isLoadingMore: _isLoadingMore, 440 - onRefresh: _loadFeed, 441 - gridItemBuilder: (context, index) => buildCard(index, PostCardVariant.grid), 442 - linearItemBuilder: (context, index) => buildCard(index, PostCardVariant.linear), 460 + return BlocProvider<AdCubit>.value( 461 + value: _adCubit, 462 + child: FeedLayoutView( 463 + itemCount: _posts.length, 464 + scrollController: _scrollController, 465 + isLoadingMore: _isLoadingMore, 466 + onRefresh: _loadFeed, 467 + gridItemBuilder: (context, index) => buildCard(index, PostCardVariant.grid), 468 + linearItemBuilder: (context, index) => buildCard(index, PostCardVariant.linear), 469 + ), 443 470 ); 444 471 } 445 472 }
+43 -13
lib/features/feed/presentation/widgets/feed_layout_view.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:lazurite/core/ads/ad_helper.dart'; 3 4 import 'package:lazurite/core/theme/feed_layout.dart'; 5 + import 'package:lazurite/features/ads/presentation/ad_slot.dart'; 4 6 import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; 5 7 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 6 8 import 'package:lazurite/features/settings/bloc/settings_state.dart'; ··· 15 17 /// [gridItemBuilder] is used when the card layout is active. 16 18 /// [linearItemBuilder] is used when the compact layout is active. 17 19 /// This allows the caller to render the appropriate card variant for each mode. 20 + /// 21 + /// When [adsRemoved] is false on [SettingsCubit], native ad slots are injected 22 + /// automatically every [AdHelper.adInterval] posts, deferred by [adOffset]. 18 23 class FeedLayoutView extends StatelessWidget { 19 24 const FeedLayoutView({ 20 25 super.key, ··· 24 29 required this.scrollController, 25 30 required this.isLoadingMore, 26 31 required this.onRefresh, 32 + this.adOffset = 0, 27 33 }); 28 34 29 35 final int itemCount; ··· 33 39 final bool isLoadingMore; 34 40 final RefreshCallback onRefresh; 35 41 42 + /// Visual items before the first ad slot. Set to [AdHelper.profileAdOffset] 43 + /// 44 + /// For profile post tabs, leave at 0 for feeds. 45 + final int adOffset; 46 + 36 47 @override 37 48 Widget build(BuildContext context) { 38 49 return BlocBuilder<SettingsCubit, SettingsState>( 39 - buildWhen: (prev, curr) => prev.feedLayout != curr.feedLayout, 50 + buildWhen: (prev, curr) => prev.feedLayout != curr.feedLayout || prev.adsRemoved != curr.adsRemoved, 40 51 builder: (context, settingsState) { 52 + final adsRemoved = settingsState.adsRemoved; 53 + final effectiveCount = adsRemoved ? itemCount : AdHelper.visualItemCount(itemCount, offset: adOffset); 54 + 55 + final wrappedGrid = adsRemoved 56 + ? gridItemBuilder 57 + : (ctx, vi) { 58 + final di = AdHelper.dataIndexForVisualIndex(vi, offset: adOffset); 59 + return di != null ? gridItemBuilder(ctx, di) : AdSlot(key: ValueKey('ad_slot_$vi'), slotIndex: vi); 60 + }; 61 + 62 + final wrappedLinear = adsRemoved 63 + ? linearItemBuilder 64 + : (ctx, vi) { 65 + final di = AdHelper.dataIndexForVisualIndex(vi, offset: adOffset); 66 + return di != null 67 + ? linearItemBuilder(ctx, di) 68 + : AdSlot(key: ValueKey('ad_slot_$vi'), slotIndex: vi, isLinear: true); 69 + }; 70 + 41 71 if (settingsState.feedLayout == FeedLayout.card) { 42 - return _buildGrid(context); 72 + return _buildGrid(context, effectiveCount, wrappedGrid); 43 73 } 44 - return _buildLinear(context); 74 + return _buildLinear(context, effectiveCount, wrappedLinear); 45 75 }, 46 76 ); 47 77 } 48 78 49 - Widget _buildGrid(BuildContext context) { 79 + Widget _buildGrid(BuildContext context, int count, IndexedWidgetBuilder builder) { 50 80 final width = MediaQuery.of(context).size.width; 51 81 final columns = feedColumnCount(width); 52 82 if (columns == 1) { 53 - return _buildSingleColumnGrid(context); 83 + return _buildSingleColumnGrid(context, count, builder); 54 84 } 55 85 final tileWidth = (width - ((columns - 1) * _gridSpacing)) / columns; 56 86 ··· 60 90 controller: scrollController, 61 91 slivers: [ 62 92 SliverGrid( 63 - delegate: SliverChildBuilderDelegate(gridItemBuilder, childCount: itemCount), 93 + delegate: SliverChildBuilderDelegate(builder, childCount: count), 64 94 gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( 65 95 crossAxisCount: columns, 66 96 crossAxisSpacing: _gridSpacing, ··· 79 109 ); 80 110 } 81 111 82 - Widget _buildSingleColumnGrid(BuildContext context) { 112 + Widget _buildSingleColumnGrid(BuildContext context, int count, IndexedWidgetBuilder builder) { 83 113 return RefreshIndicator( 84 114 onRefresh: onRefresh, 85 115 child: CustomScrollView( ··· 88 118 SliverPadding( 89 119 padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), 90 120 sliver: SliverList.separated( 91 - itemCount: itemCount, 92 - itemBuilder: gridItemBuilder, 121 + itemCount: count, 122 + itemBuilder: builder, 93 123 separatorBuilder: (_, _) => const SizedBox(height: 12), 94 124 ), 95 125 ), ··· 104 134 ); 105 135 } 106 136 107 - Widget _buildLinear(BuildContext context) { 137 + Widget _buildLinear(BuildContext context, int count, IndexedWidgetBuilder builder) { 108 138 return RefreshIndicator( 109 139 onRefresh: onRefresh, 110 140 child: ListView.builder( 111 141 controller: scrollController, 112 142 padding: const EdgeInsets.symmetric(vertical: 4), 113 - itemCount: itemCount + (isLoadingMore ? 1 : 0), 143 + itemCount: count + (isLoadingMore ? 1 : 0), 114 144 itemBuilder: (context, index) { 115 - if (index == itemCount) { 145 + if (index == count) { 116 146 return const Center( 117 147 child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 118 148 ); 119 149 } 120 - return Padding(padding: const EdgeInsets.only(bottom: 4), child: linearItemBuilder(context, index)); 150 + return Padding(padding: const EdgeInsets.only(bottom: 4), child: builder(context, index)); 121 151 }, 122 152 ), 123 153 );
+89 -13
lib/features/profile/presentation/profile_screen.dart
··· 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:go_router/go_router.dart'; 8 8 import 'package:intl/intl.dart'; 9 + import 'package:lazurite/core/ads/ad_helper.dart'; 9 10 import 'package:lazurite/core/router/app_shell.dart'; 10 11 import 'package:lazurite/core/theme/feed_layout.dart'; 11 12 import 'package:lazurite/core/widgets/sliver_tab_bar_delegate.dart'; 13 + import 'package:lazurite/features/ads/cubit/ad_cubit.dart'; 14 + import 'package:lazurite/features/ads/data/native_ad_repository.dart'; 15 + import 'package:lazurite/features/ads/presentation/ad_slot.dart'; 12 16 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 13 17 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 14 18 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; ··· 83 87 84 88 late TabController _tabController; 85 89 late bool _showSuggestedTab; 90 + late final AdCubit _adCubit; 91 + int? _lastAdPostCount; 92 + bool _lastInjectedAds = false; 86 93 87 94 @override 88 95 void initState() { 89 96 super.initState(); 97 + _adCubit = AdCubit( 98 + settingsCubit: context.read<SettingsCubit>(), 99 + nativeAdRepository: context.read<NativeAdRepository>(), 100 + ); 90 101 _showSuggestedTab = _shouldShowSuggestedTab(context.read<ProfileBloc>().state.profile); 91 102 _tabController = TabController(length: _tabLabels.length, vsync: this); 92 103 _loadProfileAndFeed(); ··· 104 115 105 116 @override 106 117 void dispose() { 118 + _adCubit.close(); 107 119 _tabController.dispose(); 108 120 super.dispose(); 109 121 } ··· 744 756 return Center(child: Text(_emptyLabel(tabFilter))); 745 757 } 746 758 759 + final showAds = tabFilter == FeedFilter.postsNoReplies; 760 + 747 761 return BlocBuilder<SettingsCubit, SettingsState>( 748 - buildWhen: (prev, curr) => prev.feedLayout != curr.feedLayout, 762 + buildWhen: (prev, curr) => prev.feedLayout != curr.feedLayout || prev.adsRemoved != curr.adsRemoved, 749 763 builder: (context, settingsState) { 764 + final injectAds = showAds && !settingsState.adsRemoved; 765 + _syncAds(feedState.posts.length, injectAds: injectAds); 750 766 if (settingsState.feedLayout == FeedLayout.card) { 751 - return _buildGridFeed(context, feedState); 767 + return BlocProvider<AdCubit>.value( 768 + value: _adCubit, 769 + child: _buildGridFeed(context, feedState, injectAds: injectAds), 770 + ); 752 771 } 753 - return _buildLinearFeed(context, feedState); 772 + return BlocProvider<AdCubit>.value( 773 + value: _adCubit, 774 + child: _buildLinearFeed(context, feedState, injectAds: injectAds), 775 + ); 754 776 }, 755 777 ); 756 778 } 757 779 758 - Widget _buildGridFeed(BuildContext context, FeedState feedState) { 780 + Widget _buildGridFeed(BuildContext context, FeedState feedState, {bool injectAds = false}) { 759 781 final accountDid = _resolvedActor ?? ''; 782 + final visualCount = injectAds 783 + ? AdHelper.visualItemCount(feedState.posts.length, offset: AdHelper.profileAdOffset) 784 + : feedState.posts.length; 760 785 761 786 return RefreshIndicator( 762 787 onRefresh: _refresh, ··· 772 797 child: ListView.builder( 773 798 key: const ValueKey('profile_grid_feed'), 774 799 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 775 - itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), 800 + itemCount: visualCount + (feedState.isLoadingMore ? 1 : 0), 776 801 itemBuilder: (context, index) { 777 - if (index >= feedState.posts.length) { 802 + if (index >= visualCount) { 778 803 return const Padding( 779 804 padding: EdgeInsets.all(16), 780 805 child: Center(child: CircularProgressIndicator()), 781 806 ); 782 807 } 783 - final post = feedState.posts[index]; 808 + 809 + final dataIndex = injectAds 810 + ? AdHelper.dataIndexForVisualIndex(index, offset: AdHelper.profileAdOffset) 811 + : index; 812 + if (dataIndex == null) { 813 + return Padding( 814 + padding: EdgeInsets.only(bottom: index == visualCount - 1 ? 0 : 16), 815 + child: Center( 816 + child: ConstrainedBox( 817 + constraints: const BoxConstraints(maxWidth: 720), 818 + child: AdSlot(key: ValueKey('ad_slot_$index'), slotIndex: index), 819 + ), 820 + ), 821 + ); 822 + } 823 + 824 + final post = feedState.posts[dataIndex]; 784 825 785 826 return Padding( 786 - padding: EdgeInsets.only(bottom: index == feedState.posts.length - 1 ? 0 : 16), 827 + padding: EdgeInsets.only(bottom: index == visualCount - 1 ? 0 : 16), 787 828 child: Center( 788 829 child: ConstrainedBox( 789 - key: ValueKey('profile_large_card_$index'), 830 + key: ValueKey('profile_large_card_$dataIndex'), 790 831 constraints: const BoxConstraints(maxWidth: 720), 791 832 child: PostCardWithActions( 792 833 feedViewPost: post, ··· 803 844 ); 804 845 } 805 846 806 - Widget _buildLinearFeed(BuildContext context, FeedState feedState) { 847 + Widget _buildLinearFeed(BuildContext context, FeedState feedState, {bool injectAds = false}) { 807 848 final accountDid = _resolvedActor ?? ''; 849 + final visualCount = injectAds 850 + ? AdHelper.visualItemCount(feedState.posts.length, offset: AdHelper.profileAdOffset) 851 + : feedState.posts.length; 808 852 return RefreshIndicator( 809 853 onRefresh: _refresh, 810 854 child: NotificationListener<ScrollNotification>( ··· 818 862 }, 819 863 child: ListView.builder( 820 864 padding: EdgeInsets.zero, 821 - itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), 865 + itemCount: visualCount + (feedState.isLoadingMore ? 1 : 0), 822 866 itemBuilder: (context, index) { 823 - if (index >= feedState.posts.length) { 867 + if (index >= visualCount) { 824 868 return const Padding( 825 869 padding: EdgeInsets.all(16), 826 870 child: Center(child: CircularProgressIndicator()), 827 871 ); 828 872 } 873 + 874 + final dataIndex = injectAds 875 + ? AdHelper.dataIndexForVisualIndex(index, offset: AdHelper.profileAdOffset) 876 + : index; 877 + if (dataIndex == null) { 878 + return AdSlot(key: ValueKey('ad_slot_$index'), slotIndex: index, isLinear: true); 879 + } 880 + 829 881 return PostCardWithActions( 830 - feedViewPost: feedState.posts[index], 882 + feedViewPost: feedState.posts[dataIndex], 831 883 accountDid: accountDid, 832 884 moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, 833 885 ); ··· 863 915 } 864 916 865 917 return _ProfileStarterPacksPane(actor: actor, starterPackRepository: starterPackRepository); 918 + } 919 + 920 + void _syncAds(int postCount, {required bool injectAds}) { 921 + if (!injectAds) { 922 + if (_lastInjectedAds) { 923 + _adCubit.clearAds(); 924 + } 925 + _lastInjectedAds = false; 926 + _lastAdPostCount = null; 927 + return; 928 + } 929 + 930 + if (_lastInjectedAds && _lastAdPostCount == postCount) { 931 + return; 932 + } 933 + 934 + _lastInjectedAds = true; 935 + _lastAdPostCount = postCount; 936 + WidgetsBinding.instance.addPostFrameCallback((_) { 937 + if (!mounted) { 938 + return; 939 + } 940 + _adCubit.loadAdsForPage(0, postCount, offset: AdHelper.profileAdOffset); 941 + }); 866 942 } 867 943 868 944 String _emptyLabel(FeedFilter filter) {
+8
lib/features/settings/bloc/settings_cubit.dart
··· 37 37 static const String _keyThreadAutoCollapseDepth = 'thread_auto_collapse_depth'; 38 38 static const String _keyConstellationUrl = 'constellation_url'; 39 39 static const String _defaultConstellationUrl = 'https://constellation.microcosm.blue'; 40 + static const String _keyAdsRemoved = 'ads_removed'; 40 41 41 42 Future<void> loadSettings() async { 42 43 final paletteStr = await database.getSetting(_keyThemePalette); ··· 47 48 final simulateOfflineStr = await database.getSetting(_keySimulateOffline); 48 49 final threadAutoCollapseDepthStr = await database.getSetting(_keyThreadAutoCollapseDepth); 49 50 final constellationUrlStr = await database.getSetting(_keyConstellationUrl); 51 + final adsRemovedStr = await database.getSetting(_keyAdsRemoved); 50 52 51 53 emit( 52 54 state.copyWith( ··· 57 59 simulateOffline: simulateOfflineStr == 'true', 58 60 threadAutoCollapseDepth: int.tryParse(threadAutoCollapseDepthStr ?? ''), 59 61 constellationUrl: constellationUrlStr ?? _defaultConstellationUrl, 62 + adsRemoved: adsRemovedStr == 'true', 60 63 ), 61 64 ); 62 65 } ··· 105 108 Future<void> setConstellationUrl(String url) async { 106 109 await database.setSetting(_keyConstellationUrl, url); 107 110 emit(state.copyWith(constellationUrl: url)); 111 + } 112 + 113 + Future<void> setAdsRemoved(bool value) async { 114 + await database.setSetting(_keyAdsRemoved, value.toString()); 115 + emit(state.copyWith(adsRemoved: value)); 108 116 } 109 117 }
+5
lib/features/settings/bloc/settings_state.dart
··· 13 13 this.simulateOffline = false, 14 14 this.threadAutoCollapseDepth, 15 15 this.constellationUrl = 'https://constellation.microcosm.blue', 16 + this.adsRemoved = false, 16 17 }); 17 18 18 19 final AppThemePalette themePalette; ··· 22 23 final bool simulateOffline; 23 24 final int? threadAutoCollapseDepth; 24 25 final String constellationUrl; 26 + final bool adsRemoved; 25 27 26 28 SettingsState copyWith({ 27 29 AppThemePalette? themePalette, ··· 31 33 bool? simulateOffline, 32 34 Object? threadAutoCollapseDepth = _threadAutoCollapseDepthUnset, 33 35 String? constellationUrl, 36 + bool? adsRemoved, 34 37 }) { 35 38 return SettingsState( 36 39 themePalette: themePalette ?? this.themePalette, ··· 42 45 ? this.threadAutoCollapseDepth 43 46 : threadAutoCollapseDepth as int?, 44 47 constellationUrl: constellationUrl ?? this.constellationUrl, 48 + adsRemoved: adsRemoved ?? this.adsRemoved, 45 49 ); 46 50 } 47 51 ··· 54 58 simulateOffline, 55 59 threadAutoCollapseDepth, 56 60 constellationUrl, 61 + adsRemoved, 57 62 ]; 58 63 }
+7
lib/features/settings/presentation/settings_screen.dart
··· 13 13 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 14 14 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 15 15 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 16 + import 'package:lazurite/features/tips/presentation/tip_sheet.dart'; 16 17 17 18 class SettingsScreen extends StatelessWidget { 18 19 const SettingsScreen({super.key}); ··· 91 92 title: 'Video Upload Limits', 92 93 subtitle: 'Check your daily video quota', 93 94 onTap: () => context.push('/settings/video-limits'), 95 + ), 96 + _SettingsTile( 97 + icon: Icons.favorite_outline, 98 + title: 'Support Lazurite', 99 + subtitle: 'Buy us a coffee — removes ads forever', 100 + onTap: () => showTipSheet(context), 94 101 ), 95 102 const SizedBox(height: 24), 96 103 _buildSectionHeader(context, 'Advanced'),
+86
lib/features/tips/cubit/tip_cubit.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:in_app_purchase/in_app_purchase.dart'; 5 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 6 + import 'package:lazurite/features/tips/cubit/tip_state.dart'; 7 + import 'package:lazurite/features/tips/data/purchase_repository.dart'; 8 + 9 + class TipCubit extends Cubit<TipState> { 10 + TipCubit({required PurchaseRepository purchaseRepository, required SettingsCubit settingsCubit}) 11 + : _repo = purchaseRepository, 12 + _settings = settingsCubit, 13 + super(TipState(adsRemoved: settingsCubit.state.adsRemoved)) { 14 + _purchaseSub = purchaseRepository.purchaseStream.listen(_onPurchaseUpdate); 15 + } 16 + 17 + final PurchaseRepository _repo; 18 + final SettingsCubit _settings; 19 + late final StreamSubscription<List<PurchaseDetails>> _purchaseSub; 20 + 21 + /// Checks store availability and fetches product details. 22 + Future<void> loadProducts() async { 23 + emit(state.copyWith(storeStatus: TipStoreStatus.loading)); 24 + try { 25 + final available = await _repo.isAvailable(); 26 + if (!available) { 27 + emit(state.copyWith(storeStatus: TipStoreStatus.unavailable)); 28 + return; 29 + } 30 + final products = await _repo.fetchProducts(); 31 + emit(state.copyWith(storeStatus: TipStoreStatus.available, products: products)); 32 + } catch (e) { 33 + emit(state.copyWith(storeStatus: TipStoreStatus.unavailable)); 34 + } 35 + } 36 + 37 + /// Initiates a tip purchase. 38 + Future<void> purchaseTip(ProductDetails product) async { 39 + emit(state.copyWith(purchaseStatus: TipPurchaseStatus.pending, clearError: true)); 40 + try { 41 + await _repo.buyTip(product); 42 + } catch (e) { 43 + emit(state.copyWith(purchaseStatus: TipPurchaseStatus.error, errorMessage: e.toString())); 44 + } 45 + } 46 + 47 + Future<void> _onPurchaseUpdate(List<PurchaseDetails> purchases) async { 48 + for (final purchase in purchases) { 49 + switch (purchase.status) { 50 + case PurchaseStatus.pending: 51 + emit(state.copyWith(purchaseStatus: TipPurchaseStatus.pending, clearError: true)); 52 + break; 53 + case PurchaseStatus.purchased: 54 + if (!state.adsRemoved) { 55 + await _settings.setAdsRemoved(true); 56 + emit(state.copyWith(adsRemoved: true)); 57 + } 58 + emit(state.copyWith(purchaseStatus: TipPurchaseStatus.success, clearError: true)); 59 + if (purchase.pendingCompletePurchase) { 60 + await _repo.completePurchase(purchase); 61 + } 62 + break; 63 + case PurchaseStatus.error: 64 + emit(state.copyWith(purchaseStatus: TipPurchaseStatus.error, errorMessage: purchase.error?.message)); 65 + if (purchase.pendingCompletePurchase) { 66 + await _repo.completePurchase(purchase); 67 + } 68 + break; 69 + case PurchaseStatus.restored: 70 + if (purchase.pendingCompletePurchase) { 71 + await _repo.completePurchase(purchase); 72 + } 73 + break; 74 + case PurchaseStatus.canceled: 75 + emit(state.copyWith(purchaseStatus: TipPurchaseStatus.idle, clearError: true)); 76 + break; 77 + } 78 + } 79 + } 80 + 81 + @override 82 + Future<void> close() { 83 + _purchaseSub.cancel(); 84 + return super.close(); 85 + } 86 + }
+42
lib/features/tips/cubit/tip_state.dart
··· 1 + import 'package:equatable/equatable.dart'; 2 + import 'package:in_app_purchase/in_app_purchase.dart'; 3 + 4 + enum TipStoreStatus { loading, available, unavailable } 5 + 6 + enum TipPurchaseStatus { idle, pending, success, error } 7 + 8 + class TipState extends Equatable { 9 + const TipState({ 10 + this.storeStatus = TipStoreStatus.loading, 11 + this.products = const [], 12 + this.purchaseStatus = TipPurchaseStatus.idle, 13 + this.errorMessage, 14 + this.adsRemoved = false, 15 + }); 16 + 17 + final TipStoreStatus storeStatus; 18 + final List<ProductDetails> products; 19 + final TipPurchaseStatus purchaseStatus; 20 + final String? errorMessage; 21 + final bool adsRemoved; 22 + 23 + TipState copyWith({ 24 + TipStoreStatus? storeStatus, 25 + List<ProductDetails>? products, 26 + TipPurchaseStatus? purchaseStatus, 27 + String? errorMessage, 28 + bool? adsRemoved, 29 + bool clearError = false, 30 + }) { 31 + return TipState( 32 + storeStatus: storeStatus ?? this.storeStatus, 33 + products: products ?? this.products, 34 + purchaseStatus: purchaseStatus ?? this.purchaseStatus, 35 + errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), 36 + adsRemoved: adsRemoved ?? this.adsRemoved, 37 + ); 38 + } 39 + 40 + @override 41 + List<Object?> get props => [storeStatus, products, purchaseStatus, errorMessage, adsRemoved]; 42 + }
+54
lib/features/tips/data/purchase_repository.dart
··· 1 + import 'package:in_app_purchase/in_app_purchase.dart'; 2 + 3 + /// Abstract purchase repository — wraps [InAppPurchase.instance] so it can be 4 + /// mocked in unit tests. 5 + abstract class PurchaseRepository { 6 + /// Product IDs for the two tip tiers. 7 + static const String coffeeProductId = 'tip_coffee'; 8 + static const String latteProductId = 'tip_latte'; 9 + static const Set<String> productIds = {coffeeProductId, latteProductId}; 10 + 11 + /// Returns true if the underlying store is available and can process 12 + /// purchases. 13 + Future<bool> isAvailable(); 14 + 15 + /// Fetches [ProductDetails] for both tip products from the store. 16 + Future<List<ProductDetails>> fetchProducts(); 17 + 18 + /// Initiates a consumable purchase for [product]. 19 + Future<void> buyTip(ProductDetails product); 20 + 21 + /// Broadcast stream of purchase updates. 22 + Stream<List<PurchaseDetails>> get purchaseStream; 23 + 24 + /// Must be called after verifying and delivering every terminal purchase. 25 + Future<void> completePurchase(PurchaseDetails details); 26 + } 27 + 28 + /// Production implementation backed by [InAppPurchase.instance]. 29 + class InAppPurchaseRepository implements PurchaseRepository { 30 + InAppPurchaseRepository({InAppPurchase? iap}) : _iap = iap ?? InAppPurchase.instance; 31 + 32 + final InAppPurchase _iap; 33 + 34 + @override 35 + Future<bool> isAvailable() => _iap.isAvailable(); 36 + 37 + @override 38 + Future<List<ProductDetails>> fetchProducts() async { 39 + final response = await _iap.queryProductDetails(PurchaseRepository.productIds); 40 + return response.productDetails; 41 + } 42 + 43 + @override 44 + Future<void> buyTip(ProductDetails product) async { 45 + final param = PurchaseParam(productDetails: product); 46 + await _iap.buyConsumable(purchaseParam: param); 47 + } 48 + 49 + @override 50 + Stream<List<PurchaseDetails>> get purchaseStream => _iap.purchaseStream; 51 + 52 + @override 53 + Future<void> completePurchase(PurchaseDetails details) => _iap.completePurchase(details); 54 + }
+226
lib/features/tips/presentation/tip_sheet.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:in_app_purchase/in_app_purchase.dart'; 4 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 5 + import 'package:lazurite/features/tips/cubit/tip_cubit.dart'; 6 + import 'package:lazurite/features/tips/cubit/tip_state.dart'; 7 + import 'package:lazurite/features/tips/data/purchase_repository.dart'; 8 + 9 + /// Opens the [TipSheet] as a modal bottom sheet. 10 + void showTipSheet(BuildContext context) { 11 + final purchaseRepo = context.read<PurchaseRepository>(); 12 + final settingsCubit = context.read<SettingsCubit>(); 13 + 14 + showModalBottomSheet<void>( 15 + context: context, 16 + isScrollControlled: true, 17 + builder: (sheetContext) => BlocProvider( 18 + create: (_) => TipCubit(purchaseRepository: purchaseRepo, settingsCubit: settingsCubit)..loadProducts(), 19 + child: const TipSheet(), 20 + ), 21 + ); 22 + } 23 + 24 + /// Modal bottom sheet for in-app tip purchases. 25 + class TipSheet extends StatelessWidget { 26 + const TipSheet({super.key}); 27 + 28 + @override 29 + Widget build(BuildContext context) { 30 + return SafeArea( 31 + child: Padding( 32 + padding: const EdgeInsets.fromLTRB(16, 24, 16, 16), 33 + child: BlocBuilder<TipCubit, TipState>( 34 + builder: (context, state) { 35 + return Column( 36 + mainAxisSize: MainAxisSize.min, 37 + crossAxisAlignment: CrossAxisAlignment.start, 38 + children: [ 39 + _buildHeader(context), 40 + const SizedBox(height: 24), 41 + if (state.adsRemoved) _buildAdsRemovedBanner(context), 42 + _buildContent(context, state), 43 + if (!state.adsRemoved) ...[const SizedBox(height: 12), _buildAdsNote(context)], 44 + ], 45 + ); 46 + }, 47 + ), 48 + ), 49 + ); 50 + } 51 + 52 + Widget _buildHeader(BuildContext context) { 53 + return Row( 54 + children: [ 55 + Container( 56 + width: 40, 57 + height: 40, 58 + decoration: BoxDecoration( 59 + color: Theme.of(context).colorScheme.primaryContainer, 60 + borderRadius: BorderRadius.circular(10), 61 + ), 62 + child: Icon(Icons.favorite, color: Theme.of(context).colorScheme.onPrimaryContainer), 63 + ), 64 + const SizedBox(width: 12), 65 + Text('Support Lazurite', style: Theme.of(context).textTheme.titleLarge), 66 + ], 67 + ); 68 + } 69 + 70 + Widget _buildAdsRemovedBanner(BuildContext context) { 71 + return Container( 72 + margin: const EdgeInsets.only(bottom: 16), 73 + padding: const EdgeInsets.all(12), 74 + decoration: BoxDecoration( 75 + color: Theme.of(context).colorScheme.primaryContainer, 76 + borderRadius: BorderRadius.circular(8), 77 + ), 78 + child: Row( 79 + children: [ 80 + Icon(Icons.check_circle_outline, color: Theme.of(context).colorScheme.onPrimaryContainer), 81 + const SizedBox(width: 8), 82 + Expanded( 83 + child: Text( 84 + 'Ads removed — thanks for your support!', 85 + style: Theme.of( 86 + context, 87 + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onPrimaryContainer), 88 + ), 89 + ), 90 + ], 91 + ), 92 + ); 93 + } 94 + 95 + Widget _buildContent(BuildContext context, TipState state) { 96 + return switch (state.storeStatus) { 97 + TipStoreStatus.loading => _buildSkeleton(), 98 + TipStoreStatus.unavailable => _buildUnavailable(context), 99 + TipStoreStatus.available => _buildProducts(context, state), 100 + }; 101 + } 102 + 103 + Widget _buildSkeleton() { 104 + return const Column( 105 + children: [ 106 + _SkeletonTile(key: Key('tip_skeleton_0')), 107 + SizedBox(height: 8), 108 + _SkeletonTile(key: Key('tip_skeleton_1')), 109 + ], 110 + ); 111 + } 112 + 113 + Widget _buildUnavailable(BuildContext context) { 114 + return Column( 115 + children: [ 116 + const Icon(Icons.store_outlined, size: 48), 117 + const SizedBox(height: 8), 118 + Text('Store unavailable', style: Theme.of(context).textTheme.titleMedium), 119 + const SizedBox(height: 4), 120 + Text( 121 + 'Please check your connection and try again.', 122 + style: Theme.of(context).textTheme.bodyMedium, 123 + textAlign: TextAlign.center, 124 + ), 125 + const SizedBox(height: 16), 126 + FilledButton(onPressed: () => context.read<TipCubit>().loadProducts(), child: const Text('Retry')), 127 + ], 128 + ); 129 + } 130 + 131 + /// Builds the canonical list: coffee first, latte second. 132 + Widget _buildProducts(BuildContext context, TipState state) { 133 + final isPending = state.purchaseStatus == TipPurchaseStatus.pending; 134 + 135 + final ordered = <(String emoji, ProductDetails product)>[]; 136 + ProductDetails? coffee; 137 + ProductDetails? latte; 138 + for (final p in state.products) { 139 + if (p.id == PurchaseRepository.coffeeProductId) coffee = p; 140 + if (p.id == PurchaseRepository.latteProductId) latte = p; 141 + } 142 + if (coffee != null) ordered.add(('☕', coffee)); 143 + if (latte != null) ordered.add(('☕☕', latte)); 144 + 145 + if (ordered.isEmpty) { 146 + return Center(child: Text('No products available', style: Theme.of(context).textTheme.bodyMedium)); 147 + } 148 + 149 + return Column( 150 + children: [ 151 + for (final (emoji, product) in ordered) ...[ 152 + _TipProductTile( 153 + emoji: emoji, 154 + product: product, 155 + isPending: isPending, 156 + onTap: isPending ? null : () => context.read<TipCubit>().purchaseTip(product), 157 + ), 158 + const SizedBox(height: 8), 159 + ], 160 + if (state.purchaseStatus == TipPurchaseStatus.error && state.errorMessage != null) 161 + Padding( 162 + padding: const EdgeInsets.only(top: 4), 163 + child: Text( 164 + state.errorMessage!, 165 + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.error), 166 + ), 167 + ), 168 + ], 169 + ); 170 + } 171 + 172 + Widget _buildAdsNote(BuildContext context) { 173 + return Text( 174 + 'Your first tip removes ads forever.', 175 + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 176 + ); 177 + } 178 + } 179 + 180 + class _TipProductTile extends StatelessWidget { 181 + const _TipProductTile({required this.emoji, required this.product, required this.isPending, required this.onTap}); 182 + 183 + final String emoji; 184 + final ProductDetails product; 185 + final bool isPending; 186 + final VoidCallback? onTap; 187 + 188 + @override 189 + Widget build(BuildContext context) { 190 + return ListTile( 191 + leading: Text(emoji, style: const TextStyle(fontSize: 24)), 192 + title: Text(product.title), 193 + subtitle: Text(product.description), 194 + trailing: isPending 195 + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) 196 + : FilledButton(onPressed: onTap, child: Text(product.price)), 197 + contentPadding: const EdgeInsets.symmetric(horizontal: 4), 198 + ); 199 + } 200 + } 201 + 202 + class _SkeletonTile extends StatelessWidget { 203 + const _SkeletonTile({super.key}); 204 + 205 + @override 206 + Widget build(BuildContext context) { 207 + final color = Theme.of(context).colorScheme.surfaceContainerHighest; 208 + return Row( 209 + children: [ 210 + Container(width: 40, height: 40, color: color), 211 + const SizedBox(width: 12), 212 + Expanded( 213 + child: Column( 214 + crossAxisAlignment: CrossAxisAlignment.start, 215 + children: [ 216 + Container(width: 120, height: 14, color: color), 217 + const SizedBox(height: 6), 218 + Container(width: 80, height: 12, color: color), 219 + ], 220 + ), 221 + ), 222 + Container(width: 60, height: 32, color: color), 223 + ], 224 + ); 225 + } 226 + }
+10
lib/main.dart
··· 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:go_router/go_router.dart'; 8 + import 'package:google_mobile_ads/google_mobile_ads.dart'; 8 9 import 'package:lazurite/core/database/app_database.dart'; 9 10 import 'package:lazurite/core/logging/app_logger.dart'; 10 11 import 'package:lazurite/core/logging/logging_bloc_observer.dart'; ··· 14 15 import 'package:lazurite/core/scheduler/post_scheduler.dart'; 15 16 import 'package:lazurite/core/theme/app_theme.dart'; 16 17 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 18 + import 'package:lazurite/features/ads/data/native_ad_repository.dart'; 17 19 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 18 20 import 'package:lazurite/features/auth/data/auth_repository.dart'; 19 21 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; ··· 40 42 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 41 43 import 'package:lazurite/features/settings/data/video_repository.dart'; 42 44 import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 45 + import 'package:lazurite/features/tips/data/purchase_repository.dart'; 43 46 44 47 Future<void> main() async { 45 48 WidgetsFlutterBinding.ensureInitialized(); ··· 60 63 61 64 final settingsCubit = SettingsCubit(database: database); 62 65 await settingsCubit.loadSettings(); 66 + 67 + if (!settingsCubit.state.adsRemoved) { 68 + await MobileAds.instance.initialize(); 69 + } 70 + 63 71 final connectivityCubit = ConnectivityCubit(simulateOffline: settingsCubit.state.simulateOffline); 64 72 65 73 final accountSwitcherCubit = AccountSwitcherCubit(database: database, authRepository: authRepository); ··· 260 268 RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), 261 269 RepositoryProvider(create: (_) => PostActionCache()), 262 270 RepositoryProvider(create: (_) => VideoRepository(bluesky: bluesky)), 271 + RepositoryProvider<NativeAdRepository>(create: (_) => GoogleMobileNativeAdRepository()), 272 + RepositoryProvider<PurchaseRepository>(create: (_) => InAppPurchaseRepository()), 263 273 RepositoryProvider.value(value: bluesky), 264 274 RepositoryProvider.value(value: widget.database), 265 275 RepositoryProvider.value(value: accountDid),
+72
pubspec.lock
··· 560 560 url: "https://pub.dev" 561 561 source: hosted 562 562 version: "6.3.3" 563 + google_mobile_ads: 564 + dependency: "direct main" 565 + description: 566 + name: google_mobile_ads 567 + sha256: f35e040875bb54e8a3455bcffed3b4ac9e9263fbf7751b9fd1ae7f30793faee8 568 + url: "https://pub.dev" 569 + source: hosted 570 + version: "7.0.0" 563 571 graphs: 564 572 dependency: transitive 565 573 description: ··· 672 680 url: "https://pub.dev" 673 681 source: hosted 674 682 version: "0.2.2" 683 + in_app_purchase: 684 + dependency: "direct main" 685 + description: 686 + name: in_app_purchase 687 + sha256: "5cddd7f463f3bddb1d37a72b95066e840d5822d66291331d7f8f05ce32c24b6c" 688 + url: "https://pub.dev" 689 + source: hosted 690 + version: "3.2.3" 691 + in_app_purchase_android: 692 + dependency: transitive 693 + description: 694 + name: in_app_purchase_android 695 + sha256: "634bee4734b17fe55f370f0ac07a22431a9666e0f3a870c6d20350856e8bbf71" 696 + url: "https://pub.dev" 697 + source: hosted 698 + version: "0.4.0+10" 699 + in_app_purchase_platform_interface: 700 + dependency: transitive 701 + description: 702 + name: in_app_purchase_platform_interface 703 + sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c" 704 + url: "https://pub.dev" 705 + source: hosted 706 + version: "1.4.0" 707 + in_app_purchase_storekit: 708 + dependency: transitive 709 + description: 710 + name: in_app_purchase_storekit 711 + sha256: "1d512809edd9f12ff88fce4596a13a18134e2499013f4d6a8894b04699363c93" 712 + url: "https://pub.dev" 713 + source: hosted 714 + version: "0.4.8+1" 675 715 intl: 676 716 dependency: "direct main" 677 717 description: ··· 1453 1493 url: "https://pub.dev" 1454 1494 source: hosted 1455 1495 version: "1.2.1" 1496 + webview_flutter: 1497 + dependency: transitive 1498 + description: 1499 + name: webview_flutter 1500 + sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 1501 + url: "https://pub.dev" 1502 + source: hosted 1503 + version: "4.13.1" 1504 + webview_flutter_android: 1505 + dependency: transitive 1506 + description: 1507 + name: webview_flutter_android 1508 + sha256: "0f7fcd2c86bf36bdcf94881f7941ce0cbc4f8d104b9fdcd5fcbef90e2199db76" 1509 + url: "https://pub.dev" 1510 + source: hosted 1511 + version: "4.10.15" 1512 + webview_flutter_platform_interface: 1513 + dependency: transitive 1514 + description: 1515 + name: webview_flutter_platform_interface 1516 + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" 1517 + url: "https://pub.dev" 1518 + source: hosted 1519 + version: "2.15.1" 1520 + webview_flutter_wkwebview: 1521 + dependency: transitive 1522 + description: 1523 + name: webview_flutter_wkwebview 1524 + sha256: d7219cfabc6f5fc2032e0fa980ec36d71f308a35a823395af1abc34d9a2ede83 1525 + url: "https://pub.dev" 1526 + source: hosted 1527 + version: "3.24.2" 1456 1528 win32: 1457 1529 dependency: transitive 1458 1530 description:
+2
pubspec.yaml
··· 47 47 gal: ^2.3.2 48 48 permission_handler: ^12.0.1 49 49 provider: ^6.1.5+1 50 + google_mobile_ads: ^7.0.0 51 + in_app_purchase: ^3.2.3 50 52 51 53 dev_dependencies: 52 54 flutter_test:
+57
test/core/ads/ad_helper_test.dart
··· 1 + import 'package:flutter/foundation.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/core/ads/ad_helper.dart'; 4 + 5 + void main() { 6 + group('AdHelper', () { 7 + test('uses the Google test unit ID for the active debug platform', () { 8 + final expected = switch (defaultTargetPlatform) { 9 + TargetPlatform.iOS => 'ca-app-pub-3940256099942544/3986624511', 10 + _ => 'ca-app-pub-3940256099942544/2247696110', 11 + }; 12 + 13 + expect(AdHelper.nativeAdUnitId, expected); 14 + }); 15 + 16 + test('visual item count injects ads every eight posts', () { 17 + expect(AdHelper.visualItemCount(0), 0); 18 + expect(AdHelper.visualItemCount(8), 9); 19 + expect(AdHelper.visualItemCount(10), 11); 20 + expect(AdHelper.visualItemCount(12, offset: AdHelper.profileAdOffset), 13); 21 + }); 22 + 23 + test('maps feed visual indices back to post indices', () { 24 + expect(AdHelper.dataIndexForVisualIndex(0), 0); 25 + expect(AdHelper.dataIndexForVisualIndex(7), 7); 26 + expect(AdHelper.dataIndexForVisualIndex(8), isNull); 27 + expect(AdHelper.dataIndexForVisualIndex(9), 8); 28 + expect(AdHelper.dataIndexForVisualIndex(10), 9); 29 + }); 30 + 31 + test('maps profile visual indices back to post indices with offset', () { 32 + expect(AdHelper.dataIndexForVisualIndex(0, offset: AdHelper.profileAdOffset), 0); 33 + expect(AdHelper.dataIndexForVisualIndex(3, offset: AdHelper.profileAdOffset), 3); 34 + expect(AdHelper.dataIndexForVisualIndex(4, offset: AdHelper.profileAdOffset), 4); 35 + expect(AdHelper.dataIndexForVisualIndex(11, offset: AdHelper.profileAdOffset), 11); 36 + expect(AdHelper.dataIndexForVisualIndex(12, offset: AdHelper.profileAdOffset), isNull); 37 + expect(AdHelper.dataIndexForVisualIndex(13, offset: AdHelper.profileAdOffset), 12); 38 + }); 39 + 40 + test('round-trips data indices for feed and profile offsets', () { 41 + for (final offset in <int>[0, AdHelper.profileAdOffset]) { 42 + final postCount = offset == 0 ? 18 : 20; 43 + final visualCount = AdHelper.visualItemCount(postCount, offset: offset); 44 + final seen = <int>[]; 45 + 46 + for (var visualIndex = 0; visualIndex < visualCount; visualIndex++) { 47 + final dataIndex = AdHelper.dataIndexForVisualIndex(visualIndex, offset: offset); 48 + if (dataIndex != null) { 49 + seen.add(dataIndex); 50 + } 51 + } 52 + 53 + expect(seen, List<int>.generate(postCount, (index) => index)); 54 + } 55 + }); 56 + }); 57 + }
+28 -3
test/core/router/app_router_test.dart
··· 7 7 import 'package:flutter_test/flutter_test.dart'; 8 8 import 'package:lazurite/core/router/app_router.dart'; 9 9 import 'package:lazurite/core/theme/app_theme.dart'; 10 + import 'package:lazurite/features/ads/data/native_ad_repository.dart'; 10 11 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 11 12 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 12 13 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; ··· 41 42 42 43 class MockNotificationRepository extends Mock implements NotificationRepository {} 43 44 45 + class _FakeNativeAdHandle implements NativeAdHandle { 46 + @override 47 + Widget buildWidget() => const SizedBox.shrink(); 48 + 49 + @override 50 + void dispose() {} 51 + } 52 + 53 + class _FakeNativeAdRepository implements NativeAdRepository { 54 + @override 55 + Future<NativeAdHandle?> loadAd({required int slotIndex}) async => _FakeNativeAdHandle(); 56 + } 57 + 44 58 void main() { 45 59 late MockAuthBloc authBloc; 46 60 late MockFeedPreferencesCubit feedPreferencesCubit; ··· 52 66 late MockUnreadCountCubit unreadCountCubit; 53 67 late MockConvoListBloc convoListBloc; 54 68 late MockNotificationRepository notificationRepository; 69 + late _FakeNativeAdRepository nativeAdRepository; 55 70 late StreamController<AuthState> authController; 56 71 late AuthState currentAuthState; 57 72 ··· 84 99 unreadCountCubit = MockUnreadCountCubit(); 85 100 convoListBloc = MockConvoListBloc(); 86 101 notificationRepository = MockNotificationRepository(); 102 + nativeAdRepository = _FakeNativeAdRepository(); 87 103 authController = StreamController<AuthState>.broadcast(); 88 104 currentAuthState = const AuthState.authenticated(tokens); 89 105 ··· 166 182 BlocProvider<UnreadCountCubit>.value(value: unreadCountCubit), 167 183 BlocProvider<ConvoListBloc>.value(value: convoListBloc), 168 184 ], 169 - child: RepositoryProvider<NotificationRepository>( 170 - create: (_) => notificationRepository, 185 + child: MultiRepositoryProvider( 186 + providers: [ 187 + RepositoryProvider<NotificationRepository>(create: (_) => notificationRepository), 188 + RepositoryProvider<NativeAdRepository>.value(value: nativeAdRepository), 189 + ], 171 190 child: MaterialApp.router(routerConfig: AppRouter(authBloc: authBloc).router), 172 191 ), 173 192 ); ··· 302 321 providers: [BlocProvider<UnreadCountCubit>.value(value: unreadCountCubit)], 303 322 child: MultiBlocProvider( 304 323 providers: [BlocProvider<ConvoListBloc>.value(value: convoListBloc)], 305 - child: RepositoryProvider<NotificationRepository>.value(value: notificationRepository, child: app), 324 + child: MultiRepositoryProvider( 325 + providers: [ 326 + RepositoryProvider<NotificationRepository>.value(value: notificationRepository), 327 + RepositoryProvider<NativeAdRepository>.value(value: nativeAdRepository), 328 + ], 329 + child: app, 330 + ), 306 331 ), 307 332 ); 308 333 },
+125
test/features/ads/cubit/ad_cubit_test.dart
··· 1 + import 'package:drift/native.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/core/database/app_database.dart'; 5 + import 'package:lazurite/features/ads/cubit/ad_cubit.dart'; 6 + import 'package:lazurite/features/ads/data/native_ad_repository.dart'; 7 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 8 + 9 + class _FakeNativeAdHandle implements NativeAdHandle { 10 + _FakeNativeAdHandle(this.label); 11 + 12 + final String label; 13 + bool disposed = false; 14 + 15 + @override 16 + Widget buildWidget() => Text(label); 17 + 18 + @override 19 + void dispose() { 20 + disposed = true; 21 + } 22 + } 23 + 24 + class _FakeNativeAdRepository implements NativeAdRepository { 25 + _FakeNativeAdRepository({Set<int>? failSlots}) : failSlots = failSlots ?? {}; 26 + 27 + final Set<int> failSlots; 28 + final List<int> requestedSlots = <int>[]; 29 + final Map<int, _FakeNativeAdHandle> handles = <int, _FakeNativeAdHandle>{}; 30 + 31 + @override 32 + Future<NativeAdHandle?> loadAd({required int slotIndex}) async { 33 + requestedSlots.add(slotIndex); 34 + if (failSlots.contains(slotIndex)) { 35 + return null; 36 + } 37 + return handles.putIfAbsent(slotIndex, () => _FakeNativeAdHandle('ad-$slotIndex')); 38 + } 39 + } 40 + 41 + void main() { 42 + late AppDatabase database; 43 + late SettingsCubit settingsCubit; 44 + 45 + setUp(() async { 46 + database = AppDatabase(executor: NativeDatabase.memory()); 47 + settingsCubit = SettingsCubit(database: database); 48 + }); 49 + 50 + tearDown(() async { 51 + await settingsCubit.close(); 52 + await database.close(); 53 + }); 54 + 55 + test('loads and stores ad slots for a page', () async { 56 + final repository = _FakeNativeAdRepository(); 57 + final cubit = AdCubit(settingsCubit: settingsCubit, nativeAdRepository: repository); 58 + 59 + await cubit.loadAdsForPage(0, 10); 60 + 61 + expect(repository.requestedSlots, [8]); 62 + expect(cubit.state.loadedAds.keys, [8]); 63 + 64 + await cubit.close(); 65 + }); 66 + 67 + test('does not reload slots that are already cached', () async { 68 + final repository = _FakeNativeAdRepository(); 69 + final cubit = AdCubit(settingsCubit: settingsCubit, nativeAdRepository: repository); 70 + 71 + await cubit.loadAdsForPage(0, 10); 72 + await cubit.loadAdsForPage(1, 18); 73 + 74 + expect(repository.requestedSlots, [8, 17]); 75 + expect(cubit.state.loadedAds.keys.toList()..sort(), [8, 17]); 76 + 77 + await cubit.close(); 78 + }); 79 + 80 + test('supports profile offset slot calculation', () async { 81 + final repository = _FakeNativeAdRepository(); 82 + final cubit = AdCubit(settingsCubit: settingsCubit, nativeAdRepository: repository); 83 + 84 + await cubit.loadAdsForPage(0, 12, offset: 4); 85 + 86 + expect(repository.requestedSlots, [12]); 87 + expect(cubit.state.loadedAds.keys, [12]); 88 + 89 + await cubit.close(); 90 + }); 91 + 92 + test('disposeAd disposes the loaded handle and removes it from state', () async { 93 + final repository = _FakeNativeAdRepository(); 94 + final cubit = AdCubit(settingsCubit: settingsCubit, nativeAdRepository: repository); 95 + 96 + await cubit.loadAdsForPage(0, 10); 97 + final handle = repository.handles[8]!; 98 + 99 + cubit.disposeAd(8); 100 + 101 + expect(handle.disposed, isTrue); 102 + expect(cubit.state.loadedAds, isEmpty); 103 + 104 + await cubit.close(); 105 + }); 106 + 107 + test('ads removed skips new loads and clears loaded ads', () async { 108 + final repository = _FakeNativeAdRepository(); 109 + final cubit = AdCubit(settingsCubit: settingsCubit, nativeAdRepository: repository); 110 + 111 + await cubit.loadAdsForPage(0, 10); 112 + final handle = repository.handles[8]!; 113 + 114 + await settingsCubit.setAdsRemoved(true); 115 + await Future<void>.delayed(Duration.zero); 116 + await cubit.loadAdsForPage(1, 18); 117 + 118 + expect(handle.disposed, isTrue); 119 + expect(cubit.state.adsRemoved, isTrue); 120 + expect(cubit.state.loadedAds, isEmpty); 121 + expect(repository.requestedSlots, [8]); 122 + 123 + await cubit.close(); 124 + }); 125 + }
+29
test/features/ads/presentation/ad_post_card_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/features/ads/presentation/ad_post_card.dart'; 4 + 5 + Widget _buildSubject({required bool isLinear}) { 6 + return MaterialApp( 7 + home: Scaffold( 8 + body: AdPostCard( 9 + isLinear: isLinear, 10 + child: const ColoredBox(color: Colors.blue, child: SizedBox(width: 100, height: 100)), 11 + ), 12 + ), 13 + ); 14 + } 15 + 16 + void main() { 17 + testWidgets('renders sponsored label in grid mode', (tester) async { 18 + await tester.pumpWidget(_buildSubject(isLinear: false)); 19 + 20 + expect(find.text('Sponsored'), findsOneWidget); 21 + }); 22 + 23 + testWidgets('renders sponsored label with linear chrome', (tester) async { 24 + await tester.pumpWidget(_buildSubject(isLinear: true)); 25 + 26 + expect(find.text('Sponsored'), findsOneWidget); 27 + expect(find.byType(Divider), findsNWidgets(2)); 28 + }); 29 + }
+108 -3
test/features/feed/presentation/home_feed_screen_test.dart
··· 2 2 3 3 import 'package:bloc_test/bloc_test.dart'; 4 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 5 + import 'package:drift/native.dart'; 5 6 import 'package:flutter/material.dart'; 6 7 import 'package:flutter_bloc/flutter_bloc.dart'; 7 8 import 'package:flutter_test/flutter_test.dart'; 9 + import 'package:lazurite/core/database/app_database.dart'; 8 10 import 'package:lazurite/core/theme/app_theme.dart'; 9 11 import 'package:lazurite/core/theme/feed_layout.dart'; 12 + import 'package:lazurite/features/ads/cubit/ad_cubit.dart'; 13 + import 'package:lazurite/features/ads/data/native_ad_repository.dart'; 10 14 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 11 15 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 12 16 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; ··· 28 32 29 33 class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 30 34 31 - SettingsState _settingsState(FeedLayout architecture) => SettingsState( 35 + class _FakeNativeAdHandle implements NativeAdHandle { 36 + _FakeNativeAdHandle(this.slotIndex); 37 + 38 + final int slotIndex; 39 + 40 + @override 41 + Widget buildWidget() => ColoredBox(key: ValueKey('fake_ad_$slotIndex'), color: Colors.blue); 42 + 43 + @override 44 + void dispose() {} 45 + } 46 + 47 + class _FakeNativeAdRepository implements NativeAdRepository { 48 + final List<int> requestedSlots = <int>[]; 49 + 50 + @override 51 + Future<NativeAdHandle?> loadAd({required int slotIndex}) async { 52 + requestedSlots.add(slotIndex); 53 + return _FakeNativeAdHandle(slotIndex); 54 + } 55 + } 56 + 57 + SettingsState _settingsState(FeedLayout architecture, {bool adsRemoved = true}) => SettingsState( 32 58 themePalette: AppThemePalette.oxocarbon, 33 59 themeVariant: AppThemeVariant.dark, 34 60 useSystemTheme: false, 35 61 feedLayout: architecture, 62 + adsRemoved: adsRemoved, 36 63 ); 37 64 38 65 const _homeFeedState = FeedPreferencesState.loaded( ··· 70 97 ); 71 98 } 72 99 100 + Widget _buildAdSubject({ 101 + required FeedLayout architecture, 102 + required SettingsCubit settingsCubit, 103 + required AdCubit adCubit, 104 + double screenWidth = 400, 105 + int itemCount = 10, 106 + }) { 107 + return MediaQuery( 108 + data: MediaQueryData(size: Size(screenWidth, 800)), 109 + child: MaterialApp( 110 + home: Scaffold( 111 + body: MultiBlocProvider( 112 + providers: [ 113 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 114 + BlocProvider<AdCubit>.value(value: adCubit), 115 + ], 116 + child: FeedLayoutView( 117 + itemCount: itemCount, 118 + scrollController: ScrollController(), 119 + isLoadingMore: false, 120 + onRefresh: () async {}, 121 + gridItemBuilder: (_, i) => SizedBox(key: ValueKey('grid-$i'), child: Text('grid $i')), 122 + linearItemBuilder: (_, i) => SizedBox(key: ValueKey('linear-$i'), child: Text('linear $i')), 123 + ), 124 + ), 125 + ), 126 + ), 127 + ); 128 + } 129 + 73 130 void main() { 74 131 Widget buildHomeSubject({ 75 132 required FeedPreferencesCubit feedPreferencesCubit, ··· 78 135 }) { 79 136 final connectivityCubit = MockConnectivityCubit(); 80 137 final authBloc = MockAuthBloc(); 138 + final settingsCubit = MockSettingsCubit(); 139 + final nativeAdRepository = _FakeNativeAdRepository(); 81 140 when(() => connectivityCubit.state).thenReturn(connectivityState); 82 141 whenListen(connectivityCubit, const Stream<ConnectivityState>.empty(), initialState: connectivityState); 83 142 when(() => authBloc.state).thenReturn( ··· 90 149 AuthTokens(accessToken: 'access', did: 'did:plc:test', handle: 'test.bsky.social'), 91 150 ), 92 151 ); 152 + when(() => settingsCubit.state).thenReturn(_settingsState(FeedLayout.card)); 153 + whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: _settingsState(FeedLayout.card)); 93 154 94 155 return MaterialApp( 95 - home: RepositoryProvider<FeedRepository>.value( 96 - value: feedRepository, 156 + home: MultiRepositoryProvider( 157 + providers: [ 158 + RepositoryProvider<FeedRepository>.value(value: feedRepository), 159 + RepositoryProvider<NativeAdRepository>.value(value: nativeAdRepository), 160 + ], 97 161 child: MultiBlocProvider( 98 162 providers: [ 99 163 BlocProvider<AuthBloc>.value(value: authBloc), 100 164 BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 101 165 BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 166 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 102 167 ], 103 168 child: const HomeFeedScreen(), 104 169 ), ··· 215 280 216 281 final listView = tester.widget<ListView>(find.byType(ListView)); 217 282 expect(listView.padding, const EdgeInsets.symmetric(vertical: 4)); 283 + }); 284 + 285 + testWidgets('injects ads at deterministic positions when ads are enabled', (tester) async { 286 + final database = AppDatabase(executor: NativeDatabase.memory()); 287 + addTearDown(database.close); 288 + final settingsCubit = SettingsCubit(database: database, initialFeedLayout: FeedLayout.compact); 289 + addTearDown(settingsCubit.close); 290 + final repository = _FakeNativeAdRepository(); 291 + final adCubit = AdCubit(settingsCubit: settingsCubit, nativeAdRepository: repository); 292 + addTearDown(adCubit.close); 293 + 294 + await tester.pumpWidget( 295 + _buildAdSubject(architecture: FeedLayout.compact, settingsCubit: settingsCubit, adCubit: adCubit), 296 + ); 297 + await tester.pumpAndSettle(); 298 + 299 + expect(find.byKey(const ValueKey('ad_slot_8')), findsOneWidget); 300 + expect(find.byKey(const ValueKey('fake_ad_8')), findsOneWidget); 301 + expect(find.text('linear 7'), findsOneWidget); 302 + expect(find.text('linear 8'), findsOneWidget); 303 + expect(repository.requestedSlots, contains(8)); 304 + }); 305 + 306 + testWidgets('does not inject ads when ads have been removed', (tester) async { 307 + final database = AppDatabase(executor: NativeDatabase.memory()); 308 + addTearDown(database.close); 309 + final settingsCubit = SettingsCubit(database: database, initialFeedLayout: FeedLayout.compact); 310 + await settingsCubit.setAdsRemoved(true); 311 + addTearDown(settingsCubit.close); 312 + final repository = _FakeNativeAdRepository(); 313 + final adCubit = AdCubit(settingsCubit: settingsCubit, nativeAdRepository: repository); 314 + addTearDown(adCubit.close); 315 + 316 + await tester.pumpWidget( 317 + _buildAdSubject(architecture: FeedLayout.compact, settingsCubit: settingsCubit, adCubit: adCubit), 318 + ); 319 + await tester.pumpAndSettle(); 320 + 321 + expect(find.byKey(const ValueKey('ad_slot_8')), findsNothing); 322 + expect(repository.requestedSlots, isEmpty); 218 323 }); 219 324 }); 220 325
+106 -19
test/features/profile/presentation/profile_screen_test.dart
··· 11 11 import 'package:go_router/go_router.dart'; 12 12 import 'package:lazurite/core/theme/app_theme.dart'; 13 13 import 'package:lazurite/core/theme/feed_layout.dart'; 14 + import 'package:lazurite/features/ads/data/native_ad_repository.dart'; 14 15 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 15 16 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 16 17 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; ··· 50 51 51 52 class MockListRepository extends Mock implements ListRepository {} 52 53 54 + class _FakeNativeAdHandle implements NativeAdHandle { 55 + _FakeNativeAdHandle(this.slotIndex); 56 + 57 + final int slotIndex; 58 + 59 + @override 60 + Widget buildWidget() => ColoredBox(key: ValueKey('profile_fake_ad_$slotIndex'), color: Colors.blue); 61 + 62 + @override 63 + void dispose() {} 64 + } 65 + 66 + class _FakeNativeAdRepository implements NativeAdRepository { 67 + final List<int> requestedSlots = <int>[]; 68 + 69 + @override 70 + Future<NativeAdHandle?> loadAd({required int slotIndex}) async { 71 + requestedSlots.add(slotIndex); 72 + return _FakeNativeAdHandle(slotIndex); 73 + } 74 + } 75 + 53 76 void main() { 54 77 late MockAuthBloc authBloc; 55 78 late MockProfileBloc profileBloc; ··· 57 80 late MockSettingsCubit settingsCubit; 58 81 late MockConnectivityCubit connectivityCubit; 59 82 late MockProfileRepository profileRepository; 83 + late _FakeNativeAdRepository nativeAdRepository; 60 84 61 85 const tokens = AuthTokens( 62 86 accessToken: 'access', ··· 99 123 settingsCubit = MockSettingsCubit(); 100 124 connectivityCubit = MockConnectivityCubit(); 101 125 profileRepository = MockProfileRepository(); 126 + nativeAdRepository = _FakeNativeAdRepository(); 102 127 103 128 when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 104 129 when(() => profileBloc.state).thenReturn(ProfileState.loaded(profile: profile)); ··· 129 154 }); 130 155 131 156 Widget buildSubject() { 132 - return MultiBlocProvider( 133 - providers: [ 134 - BlocProvider<AuthBloc>.value(value: authBloc), 135 - BlocProvider<ProfileBloc>.value(value: profileBloc), 136 - BlocProvider<FeedBloc>.value(value: feedBloc), 137 - BlocProvider<SettingsCubit>.value(value: settingsCubit), 138 - BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 139 - ], 140 - child: const MaterialApp(home: ProfileScreen()), 157 + return RepositoryProvider<NativeAdRepository>.value( 158 + value: nativeAdRepository, 159 + child: MultiBlocProvider( 160 + providers: [ 161 + BlocProvider<AuthBloc>.value(value: authBloc), 162 + BlocProvider<ProfileBloc>.value(value: profileBloc), 163 + BlocProvider<FeedBloc>.value(value: feedBloc), 164 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 165 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 166 + ], 167 + child: const MaterialApp(home: ProfileScreen()), 168 + ), 141 169 ); 142 170 } 143 171 ··· 197 225 final mockProfileActionRepository = MockProfileActionRepository(); 198 226 199 227 final widget = MultiRepositoryProvider( 200 - providers: [RepositoryProvider<ProfileActionRepository>.value(value: mockProfileActionRepository)], 228 + providers: [ 229 + RepositoryProvider<NativeAdRepository>.value(value: nativeAdRepository), 230 + RepositoryProvider<ProfileActionRepository>.value(value: mockProfileActionRepository), 231 + ], 201 232 child: MultiBlocProvider( 202 233 providers: [ 203 234 BlocProvider<AuthBloc>.value(value: authBloc), ··· 257 288 await tester.pumpWidget( 258 289 MultiRepositoryProvider( 259 290 providers: [ 291 + RepositoryProvider<NativeAdRepository>.value(value: nativeAdRepository), 260 292 RepositoryProvider<ProfileRepository>.value(value: profileRepository), 261 293 RepositoryProvider<ProfileActionRepository>.value(value: mockProfileActionRepository), 262 294 ], ··· 303 335 GoRoute( 304 336 path: '/', 305 337 builder: (context, state) => MultiRepositoryProvider( 306 - providers: [RepositoryProvider<ProfileActionRepository>.value(value: mockProfileActionRepository)], 338 + providers: [ 339 + RepositoryProvider<NativeAdRepository>.value(value: nativeAdRepository), 340 + RepositoryProvider<ProfileActionRepository>.value(value: mockProfileActionRepository), 341 + ], 307 342 child: MultiBlocProvider( 308 343 providers: [ 309 344 BlocProvider<AuthBloc>.value(value: authBloc), ··· 442 477 443 478 final posts = List.generate(3, (i) => makePost('$i')); 444 479 445 - FeedState feedStateWith(List<FeedViewPost> p) => 446 - FeedState.loaded(actor: 'did:plc:me', posts: p, filter: FeedFilter.postsNoReplies, hasMore: false); 480 + FeedState feedStateWith(List<FeedViewPost> p, {FeedFilter filter = FeedFilter.postsNoReplies}) => 481 + FeedState.loaded(actor: 'did:plc:me', posts: p, filter: filter, hasMore: false); 447 482 448 483 /// Builds the profile screen with [posts] in the feed and the given SettingsCubit controlling layout mode. 449 - Widget buildWithPosts(WidgetTester tester, MockSettingsCubit settCubit) { 484 + Widget buildWithPosts( 485 + WidgetTester tester, 486 + MockSettingsCubit settCubit, { 487 + List<FeedViewPost>? customPosts, 488 + FeedFilter filter = FeedFilter.postsNoReplies, 489 + }) { 450 490 useLargeScreen(tester); 491 + final resolvedPosts = customPosts ?? posts; 451 492 452 493 final mockPostActionRepo = MockPostActionRepository(); 453 494 final mockSavedPostsCubit = MockSavedPostsCubit(); ··· 456 497 when(() => mockSavedPostsCubit.state).thenReturn(const SavedPostsState()); 457 498 whenListen(mockSavedPostsCubit, const Stream<SavedPostsState>.empty()); 458 499 459 - when(() => feedBloc.state).thenReturn(feedStateWith(posts)); 460 - whenListen(feedBloc, const Stream<FeedState>.empty(), initialState: feedStateWith(posts)); 500 + when(() => feedBloc.state).thenReturn(feedStateWith(resolvedPosts, filter: filter)); 501 + whenListen(feedBloc, const Stream<FeedState>.empty(), initialState: feedStateWith(resolvedPosts, filter: filter)); 461 502 462 503 return MultiRepositoryProvider( 463 504 providers: [ 505 + RepositoryProvider<NativeAdRepository>.value(value: nativeAdRepository), 464 506 RepositoryProvider<PostActionRepository>.value(value: mockPostActionRepo), 465 507 RepositoryProvider<PostActionCache>.value(value: mockPostActionCache), 466 508 ], ··· 491 533 expect(find.byKey(const ValueKey('profile_large_card_0')), findsOneWidget); 492 534 expect(find.byKey(const ValueKey('profile_large_card_1')), findsOneWidget); 493 535 expect(find.byKey(const ValueKey('profile_large_card_2')), findsOneWidget); 536 + }); 537 + 538 + testWidgets('posts tab respects the profile ad offset', (tester) async { 539 + final cubit = MockSettingsCubit(); 540 + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.compact)); 541 + whenListen(cubit, const Stream<SettingsState>.empty(), initialState: settingsStateWith(FeedLayout.compact)); 542 + final manyPosts = List.generate(12, (i) => makePost('$i')); 543 + 544 + await tester.pumpWidget(buildWithPosts(tester, cubit, customPosts: manyPosts)); 545 + await tester.pumpAndSettle(); 546 + await tester.scrollUntilVisible( 547 + find.byKey(const ValueKey('ad_slot_12')), 548 + 400, 549 + scrollable: find.byType(Scrollable).first, 550 + ); 551 + await tester.pumpAndSettle(); 552 + 553 + expect(find.byKey(const ValueKey('ad_slot_12')), findsOneWidget); 554 + expect(find.byKey(const ValueKey('profile_fake_ad_12')), findsOneWidget); 555 + expect(nativeAdRepository.requestedSlots, contains(12)); 494 556 }); 495 557 496 558 testWidgets('linear mode does not show the large grid card feed or metadata info card', (tester) async { ··· 530 592 531 593 await streamCtrl.close(); 532 594 }); 595 + 596 + testWidgets('non-post tabs do not render ads', (tester) async { 597 + final cubit = MockSettingsCubit(); 598 + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.compact)); 599 + whenListen(cubit, const Stream<SettingsState>.empty(), initialState: settingsStateWith(FeedLayout.compact)); 600 + final manyPosts = List.generate(12, (i) => makePost('$i')); 601 + 602 + await tester.pumpWidget( 603 + buildWithPosts(tester, cubit, customPosts: manyPosts, filter: FeedFilter.postsAndAuthorThreads), 604 + ); 605 + await tester.pumpAndSettle(); 606 + await tester.tap(find.text('REPLIES')); 607 + await tester.pumpAndSettle(); 608 + 609 + expect(find.byKey(const ValueKey('ad_slot_12')), findsNothing); 610 + }); 533 611 }); 534 612 535 613 group('Lists tab', () { ··· 566 644 BlocProvider<SettingsCubit>.value(value: settingsCubit), 567 645 ], 568 646 child: MultiRepositoryProvider( 569 - providers: [RepositoryProvider<ListRepository>.value(value: listRepository)], 647 + providers: [ 648 + RepositoryProvider<NativeAdRepository>.value(value: nativeAdRepository), 649 + RepositoryProvider<ListRepository>.value(value: listRepository), 650 + ], 570 651 child: const MaterialApp(home: ProfileScreen()), 571 652 ), 572 653 ), ··· 598 679 599 680 await tester.pumpWidget( 600 681 MultiRepositoryProvider( 601 - providers: [RepositoryProvider<ProfileActionRepository>.value(value: mockProfileActionRepository)], 682 + providers: [ 683 + RepositoryProvider<NativeAdRepository>.value(value: nativeAdRepository), 684 + RepositoryProvider<ProfileActionRepository>.value(value: mockProfileActionRepository), 685 + ], 602 686 child: MultiBlocProvider( 603 687 providers: [ 604 688 BlocProvider<AuthBloc>.value(value: authBloc), ··· 640 724 641 725 await tester.pumpWidget( 642 726 MultiRepositoryProvider( 643 - providers: [RepositoryProvider<ProfileActionRepository>.value(value: mockProfileActionRepository)], 727 + providers: [ 728 + RepositoryProvider<NativeAdRepository>.value(value: nativeAdRepository), 729 + RepositoryProvider<ProfileActionRepository>.value(value: mockProfileActionRepository), 730 + ], 644 731 child: MultiBlocProvider( 645 732 providers: [ 646 733 BlocProvider<AuthBloc>.value(value: authBloc),
+17 -2
test/features/settings/bloc/settings_cubit_test.dart
··· 27 27 expect(cubit.state.feedLayout, FeedLayout.card); 28 28 expect(cubit.state.simulateOffline, false); 29 29 expect(cubit.state.threadAutoCollapseDepth, isNull); 30 + expect(cubit.state.adsRemoved, false); 30 31 }); 31 32 32 33 test('accepts initial values via constructor', () { ··· 57 58 await database.setSetting('feed_architecture', 'linear'); 58 59 await database.setSetting('simulate_offline', 'true'); 59 60 await database.setSetting('thread_auto_collapse_depth', '4'); 61 + await database.setSetting('ads_removed', 'true'); 60 62 }, 61 63 act: (cubit) => cubit.loadSettings(), 62 64 expect: () => [ ··· 66 68 .having((s) => s.useSystemTheme, 'useSystemTheme', true) 67 69 .having((s) => s.feedLayout, 'feedLayout', FeedLayout.compact) 68 70 .having((s) => s.simulateOffline, 'simulateOffline', true) 69 - .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 4), 71 + .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 4) 72 + .having((s) => s.adsRemoved, 'adsRemoved', true), 70 73 ], 71 74 ); 72 75 ··· 81 84 .having((s) => s.useSystemTheme, 'useSystemTheme', false) 82 85 .having((s) => s.feedLayout, 'feedLayout', FeedLayout.card) 83 86 .having((s) => s.simulateOffline, 'simulateOffline', false) 84 - .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull), 87 + .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull) 88 + .having((s) => s.adsRemoved, 'adsRemoved', false), 85 89 ], 86 90 ); 87 91 ··· 202 206 verify: (cubit) async { 203 207 final value = await database.getSetting('thread_auto_collapse_depth'); 204 208 expect(value, isNull); 209 + }, 210 + ); 211 + 212 + blocTest<SettingsCubit, SettingsState>( 213 + 'setAdsRemoved updates state and persists to database', 214 + build: () => SettingsCubit(database: database), 215 + act: (cubit) => cubit.setAdsRemoved(true), 216 + expect: () => [isA<SettingsState>().having((s) => s.adsRemoved, 'adsRemoved', true)], 217 + verify: (cubit) async { 218 + final value = await database.getSetting('ads_removed'); 219 + expect(value, 'true'); 205 220 }, 206 221 ); 207 222
+44 -14
test/features/settings/presentation/settings_screen_test.dart
··· 5 5 import 'package:flutter_bloc/flutter_bloc.dart'; 6 6 import 'package:flutter_test/flutter_test.dart'; 7 7 import 'package:go_router/go_router.dart'; 8 + import 'package:in_app_purchase/in_app_purchase.dart'; 8 9 import 'package:lazurite/core/database/app_database.dart'; 9 10 import 'package:lazurite/core/theme/app_theme.dart'; 10 11 import 'package:lazurite/core/theme/feed_layout.dart'; ··· 14 15 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 15 16 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 16 17 import 'package:lazurite/features/settings/presentation/settings_screen.dart'; 18 + import 'package:lazurite/features/tips/data/purchase_repository.dart'; 17 19 import 'package:mocktail/mocktail.dart'; 18 20 19 21 class MockAccountSwitcherCubit extends MockCubit<AccountSwitcherState> implements AccountSwitcherCubit {} ··· 22 24 23 25 class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 24 26 27 + class MockPurchaseRepository extends Mock implements PurchaseRepository {} 28 + 25 29 void main() { 26 30 late MockAccountSwitcherCubit accountSwitcherCubit; 27 31 late MockAuthBloc authBloc; 28 32 late MockSettingsCubit settingsCubit; 33 + late MockPurchaseRepository purchaseRepository; 29 34 30 35 setUp(() { 31 36 accountSwitcherCubit = MockAccountSwitcherCubit(); 32 37 authBloc = MockAuthBloc(); 33 38 settingsCubit = MockSettingsCubit(); 39 + purchaseRepository = MockPurchaseRepository(); 34 40 35 41 when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); 36 42 whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.unauthenticated()); ··· 59 65 feedLayout: FeedLayout.card, 60 66 ), 61 67 ); 68 + when(() => purchaseRepository.purchaseStream).thenAnswer((_) => const Stream<List<PurchaseDetails>>.empty()); 69 + when(() => purchaseRepository.isAvailable()).thenAnswer((_) async => false); 70 + when(() => purchaseRepository.fetchProducts()).thenAnswer((_) async => const []); 62 71 }); 63 72 64 73 Widget buildSubject() { 65 - return MultiBlocProvider( 66 - providers: [ 67 - BlocProvider<AuthBloc>.value(value: authBloc), 68 - BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 69 - BlocProvider<SettingsCubit>.value(value: settingsCubit), 70 - ], 71 - child: const MaterialApp(home: SettingsScreen()), 74 + return RepositoryProvider<PurchaseRepository>.value( 75 + value: purchaseRepository, 76 + child: MultiBlocProvider( 77 + providers: [ 78 + BlocProvider<AuthBloc>.value(value: authBloc), 79 + BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 80 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 81 + ], 82 + child: const MaterialApp(home: SettingsScreen()), 83 + ), 72 84 ); 73 85 } 74 86 ··· 77 89 routes: [ 78 90 GoRoute( 79 91 path: '/', 80 - builder: (context, state) => MultiBlocProvider( 81 - providers: [ 82 - BlocProvider<AuthBloc>.value(value: authBloc), 83 - BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 84 - BlocProvider<SettingsCubit>.value(value: settingsCubit), 85 - ], 86 - child: const SettingsScreen(), 92 + builder: (context, state) => RepositoryProvider<PurchaseRepository>.value( 93 + value: purchaseRepository, 94 + child: MultiBlocProvider( 95 + providers: [ 96 + BlocProvider<AuthBloc>.value(value: authBloc), 97 + BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 98 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 99 + ], 100 + child: const SettingsScreen(), 101 + ), 87 102 ), 88 103 ), 89 104 GoRoute( ··· 264 279 265 280 expect(find.text('Video Upload Limits'), findsOneWidget); 266 281 expect(find.text('Check your daily video quota'), findsOneWidget); 282 + }); 283 + 284 + testWidgets('shows Support Lazurite row and opens the tip sheet', (tester) async { 285 + await tester.pumpWidget(buildSubject()); 286 + await tester.pumpAndSettle(); 287 + 288 + await tester.scrollUntilVisible(find.text('Support Lazurite'), 300); 289 + await tester.pumpAndSettle(); 290 + 291 + expect(find.text('Support Lazurite'), findsOneWidget); 292 + 293 + await tester.tap(find.text('Support Lazurite')); 294 + await tester.pumpAndSettle(); 295 + 296 + expect(find.text('Store unavailable'), findsOneWidget); 267 297 }); 268 298 } 269 299
+159
test/features/tips/cubit/tip_cubit_test.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:drift/native.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:in_app_purchase/in_app_purchase.dart'; 6 + import 'package:lazurite/core/database/app_database.dart'; 7 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 8 + import 'package:lazurite/features/tips/cubit/tip_cubit.dart'; 9 + import 'package:lazurite/features/tips/cubit/tip_state.dart'; 10 + import 'package:lazurite/features/tips/data/purchase_repository.dart'; 11 + import 'package:mocktail/mocktail.dart'; 12 + 13 + class MockPurchaseRepository extends Mock implements PurchaseRepository {} 14 + 15 + PurchaseDetails _purchase({ 16 + required PurchaseStatus status, 17 + String productId = PurchaseRepository.coffeeProductId, 18 + bool pendingCompletePurchase = false, 19 + String? errorMessage, 20 + }) { 21 + final purchase = PurchaseDetails( 22 + productID: productId, 23 + verificationData: PurchaseVerificationData( 24 + localVerificationData: 'local', 25 + serverVerificationData: 'server', 26 + source: 'test', 27 + ), 28 + transactionDate: '123', 29 + status: status, 30 + ); 31 + purchase.pendingCompletePurchase = pendingCompletePurchase; 32 + if (errorMessage != null) { 33 + purchase.error = IAPError(source: 'test', code: 'purchase-error', message: errorMessage); 34 + } 35 + return purchase; 36 + } 37 + 38 + void main() { 39 + late AppDatabase database; 40 + late SettingsCubit settingsCubit; 41 + late MockPurchaseRepository repository; 42 + late StreamController<List<PurchaseDetails>> purchaseController; 43 + late ProductDetails coffee; 44 + 45 + setUp(() async { 46 + database = AppDatabase(executor: NativeDatabase.memory()); 47 + settingsCubit = SettingsCubit(database: database); 48 + repository = MockPurchaseRepository(); 49 + purchaseController = StreamController<List<PurchaseDetails>>.broadcast(); 50 + coffee = ProductDetails( 51 + id: PurchaseRepository.coffeeProductId, 52 + title: 'Coffee', 53 + description: 'Small tip', 54 + price: r'$1.99', 55 + rawPrice: 1.99, 56 + currencyCode: 'USD', 57 + currencySymbol: r'$', 58 + ); 59 + registerFallbackValue( 60 + PurchaseDetails( 61 + productID: coffee.id, 62 + verificationData: PurchaseVerificationData( 63 + localVerificationData: 'local', 64 + serverVerificationData: 'server', 65 + source: 'test', 66 + ), 67 + transactionDate: '123', 68 + status: PurchaseStatus.purchased, 69 + ), 70 + ); 71 + 72 + when(() => repository.purchaseStream).thenAnswer((_) => purchaseController.stream); 73 + when(() => repository.isAvailable()).thenAnswer((_) async => true); 74 + when(() => repository.fetchProducts()).thenAnswer((_) async => [coffee]); 75 + when(() => repository.buyTip(coffee)).thenAnswer((_) async {}); 76 + when(() => repository.completePurchase(any())).thenAnswer((_) async {}); 77 + }); 78 + 79 + tearDown(() async { 80 + await purchaseController.close(); 81 + await settingsCubit.close(); 82 + await database.close(); 83 + }); 84 + 85 + test('loadProducts exposes available products', () async { 86 + final cubit = TipCubit(purchaseRepository: repository, settingsCubit: settingsCubit); 87 + 88 + await cubit.loadProducts(); 89 + 90 + expect(cubit.state.storeStatus, TipStoreStatus.available); 91 + expect(cubit.state.products, [coffee]); 92 + 93 + await cubit.close(); 94 + }); 95 + 96 + test('loadProducts reports unavailable store', () async { 97 + when(() => repository.isAvailable()).thenAnswer((_) async => false); 98 + final cubit = TipCubit(purchaseRepository: repository, settingsCubit: settingsCubit); 99 + 100 + await cubit.loadProducts(); 101 + 102 + expect(cubit.state.storeStatus, TipStoreStatus.unavailable); 103 + 104 + await cubit.close(); 105 + }); 106 + 107 + test('purchaseTip reports request errors', () async { 108 + when(() => repository.buyTip(coffee)).thenThrow(Exception('boom')); 109 + final cubit = TipCubit(purchaseRepository: repository, settingsCubit: settingsCubit); 110 + 111 + await cubit.purchaseTip(coffee); 112 + 113 + expect(cubit.state.purchaseStatus, TipPurchaseStatus.error); 114 + expect(cubit.state.errorMessage, contains('boom')); 115 + 116 + await cubit.close(); 117 + }); 118 + 119 + test('purchase stream success sets ads removed and completes the purchase', () async { 120 + final cubit = TipCubit(purchaseRepository: repository, settingsCubit: settingsCubit); 121 + 122 + purchaseController.add([_purchase(status: PurchaseStatus.purchased, pendingCompletePurchase: true)]); 123 + await Future<void>.delayed(Duration.zero); 124 + 125 + expect(cubit.state.purchaseStatus, TipPurchaseStatus.success); 126 + expect(cubit.state.adsRemoved, isTrue); 127 + expect(settingsCubit.state.adsRemoved, isTrue); 128 + expect(await database.getSetting('ads_removed'), 'true'); 129 + verify(() => repository.completePurchase(any())).called(1); 130 + 131 + await cubit.close(); 132 + }); 133 + 134 + test('purchase stream error exposes the store error', () async { 135 + final cubit = TipCubit(purchaseRepository: repository, settingsCubit: settingsCubit); 136 + 137 + purchaseController.add([ 138 + _purchase(status: PurchaseStatus.error, pendingCompletePurchase: true, errorMessage: 'Purchase failed'), 139 + ]); 140 + await Future<void>.delayed(Duration.zero); 141 + 142 + expect(cubit.state.purchaseStatus, TipPurchaseStatus.error); 143 + expect(cubit.state.errorMessage, 'Purchase failed'); 144 + verify(() => repository.completePurchase(any())).called(1); 145 + 146 + await cubit.close(); 147 + }); 148 + 149 + test('pending purchase updates keep the sheet in loading state', () async { 150 + final cubit = TipCubit(purchaseRepository: repository, settingsCubit: settingsCubit); 151 + 152 + purchaseController.add([_purchase(status: PurchaseStatus.pending)]); 153 + await Future<void>.delayed(Duration.zero); 154 + 155 + expect(cubit.state.purchaseStatus, TipPurchaseStatus.pending); 156 + 157 + await cubit.close(); 158 + }); 159 + }
+93
test/features/tips/data/purchase_repository_test.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:in_app_purchase/in_app_purchase.dart'; 5 + import 'package:lazurite/features/tips/data/purchase_repository.dart'; 6 + import 'package:mocktail/mocktail.dart'; 7 + 8 + class MockInAppPurchase extends Mock implements InAppPurchase {} 9 + 10 + void main() { 11 + late MockInAppPurchase iap; 12 + late StreamController<List<PurchaseDetails>> purchaseController; 13 + late ProductDetails product; 14 + late PurchaseDetails purchase; 15 + 16 + setUp(() { 17 + iap = MockInAppPurchase(); 18 + purchaseController = StreamController<List<PurchaseDetails>>.broadcast(); 19 + product = ProductDetails( 20 + id: PurchaseRepository.coffeeProductId, 21 + title: 'Coffee', 22 + description: 'Small tip', 23 + price: r'$1.99', 24 + rawPrice: 1.99, 25 + currencyCode: 'USD', 26 + currencySymbol: r'$', 27 + ); 28 + purchase = PurchaseDetails( 29 + productID: product.id, 30 + verificationData: PurchaseVerificationData( 31 + localVerificationData: 'local', 32 + serverVerificationData: 'server', 33 + source: 'test', 34 + ), 35 + transactionDate: '123', 36 + status: PurchaseStatus.purchased, 37 + ); 38 + registerFallbackValue(PurchaseParam(productDetails: product)); 39 + registerFallbackValue(purchase); 40 + 41 + when(() => iap.purchaseStream).thenAnswer((_) => purchaseController.stream); 42 + when(() => iap.isAvailable()).thenAnswer((_) async => true); 43 + when( 44 + () => iap.queryProductDetails(PurchaseRepository.productIds), 45 + ).thenAnswer((_) async => ProductDetailsResponse(productDetails: [product], notFoundIDs: const [])); 46 + when(() => iap.buyConsumable(purchaseParam: any(named: 'purchaseParam'))).thenAnswer((_) async => true); 47 + when(() => iap.completePurchase(any())).thenAnswer((_) async {}); 48 + }); 49 + 50 + tearDown(() async { 51 + await purchaseController.close(); 52 + }); 53 + 54 + test('delegates store availability checks', () async { 55 + final repository = InAppPurchaseRepository(iap: iap); 56 + 57 + expect(await repository.isAvailable(), isTrue); 58 + verify(() => iap.isAvailable()).called(1); 59 + }); 60 + 61 + test('queries the configured tip products', () async { 62 + final repository = InAppPurchaseRepository(iap: iap); 63 + 64 + final result = await repository.fetchProducts(); 65 + 66 + expect(result, [product]); 67 + verify(() => iap.queryProductDetails(PurchaseRepository.productIds)).called(1); 68 + }); 69 + 70 + test('starts a consumable purchase', () async { 71 + final repository = InAppPurchaseRepository(iap: iap); 72 + 73 + await repository.buyTip(product); 74 + 75 + verify(() => iap.buyConsumable(purchaseParam: any(named: 'purchaseParam'))).called(1); 76 + }); 77 + 78 + test('exposes the purchase stream and completes purchases', () async { 79 + final repository = InAppPurchaseRepository(iap: iap); 80 + final emitted = <List<PurchaseDetails>>[]; 81 + final sub = repository.purchaseStream.listen(emitted.add); 82 + purchaseController.add([purchase]); 83 + await Future<void>.delayed(Duration.zero); 84 + 85 + await repository.completePurchase(purchase); 86 + 87 + expect(emitted, [ 88 + [purchase], 89 + ]); 90 + verify(() => iap.completePurchase(purchase)).called(1); 91 + await sub.cancel(); 92 + }); 93 + }
+108
test/features/tips/presentation/tip_sheet_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:in_app_purchase/in_app_purchase.dart'; 6 + import 'package:lazurite/features/tips/cubit/tip_cubit.dart'; 7 + import 'package:lazurite/features/tips/cubit/tip_state.dart'; 8 + import 'package:lazurite/features/tips/presentation/tip_sheet.dart'; 9 + import 'package:mocktail/mocktail.dart'; 10 + 11 + class MockTipCubit extends MockCubit<TipState> implements TipCubit {} 12 + 13 + void main() { 14 + late MockTipCubit cubit; 15 + late ProductDetails coffee; 16 + late ProductDetails latte; 17 + 18 + setUp(() { 19 + cubit = MockTipCubit(); 20 + coffee = ProductDetails( 21 + id: 'tip_coffee', 22 + title: 'Coffee', 23 + description: 'Small tip', 24 + price: r'$1.99', 25 + rawPrice: 1.99, 26 + currencyCode: 'USD', 27 + currencySymbol: r'$', 28 + ); 29 + latte = ProductDetails( 30 + id: 'tip_latte', 31 + title: 'Latte', 32 + description: 'Large tip', 33 + price: r'$4.99', 34 + rawPrice: 4.99, 35 + currencyCode: 'USD', 36 + currencySymbol: r'$', 37 + ); 38 + registerFallbackValue(coffee); 39 + 40 + when(() => cubit.loadProducts()).thenAnswer((_) async {}); 41 + when(() => cubit.purchaseTip(any())).thenAnswer((_) async {}); 42 + }); 43 + 44 + Widget buildSubject(TipState state) { 45 + when(() => cubit.state).thenReturn(state); 46 + whenListen(cubit, const Stream<TipState>.empty(), initialState: state); 47 + 48 + return MaterialApp( 49 + home: Scaffold( 50 + body: BlocProvider<TipCubit>.value(value: cubit, child: const TipSheet()), 51 + ), 52 + ); 53 + } 54 + 55 + testWidgets('renders loading skeletons while products load', (tester) async { 56 + await tester.pumpWidget(buildSubject(const TipState(storeStatus: TipStoreStatus.loading))); 57 + 58 + expect(find.byKey(const Key('tip_skeleton_0')), findsOneWidget); 59 + expect(find.byKey(const Key('tip_skeleton_1')), findsOneWidget); 60 + }); 61 + 62 + testWidgets('renders products with localized prices and ads note', (tester) async { 63 + await tester.pumpWidget( 64 + buildSubject(TipState(storeStatus: TipStoreStatus.available, products: [latte, coffee], adsRemoved: false)), 65 + ); 66 + 67 + expect(find.text('Coffee'), findsOneWidget); 68 + expect(find.text('Latte'), findsOneWidget); 69 + expect(find.text(r'$1.99'), findsOneWidget); 70 + expect(find.text(r'$4.99'), findsOneWidget); 71 + expect(find.text('Your first tip removes ads forever.'), findsOneWidget); 72 + }); 73 + 74 + testWidgets('renders thank-you banner when ads are already removed', (tester) async { 75 + await tester.pumpWidget( 76 + buildSubject(TipState(storeStatus: TipStoreStatus.available, products: [coffee, latte], adsRemoved: true)), 77 + ); 78 + 79 + expect(find.text('Ads removed — thanks for your support!'), findsOneWidget); 80 + expect(find.text('Your first tip removes ads forever.'), findsNothing); 81 + }); 82 + 83 + testWidgets('renders store unavailable state and retries', (tester) async { 84 + await tester.pumpWidget(buildSubject(const TipState(storeStatus: TipStoreStatus.unavailable))); 85 + 86 + expect(find.text('Store unavailable'), findsOneWidget); 87 + 88 + await tester.tap(find.text('Retry')); 89 + await tester.pump(); 90 + 91 + verify(() => cubit.loadProducts()).called(1); 92 + }); 93 + 94 + testWidgets('shows pending spinner and disables other purchases', (tester) async { 95 + await tester.pumpWidget( 96 + buildSubject( 97 + TipState( 98 + storeStatus: TipStoreStatus.available, 99 + products: [coffee, latte], 100 + purchaseStatus: TipPurchaseStatus.pending, 101 + ), 102 + ), 103 + ); 104 + 105 + expect(find.byType(CircularProgressIndicator), findsNWidgets(2)); 106 + expect(find.byType(FilledButton), findsNothing); 107 + }); 108 + }