diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9324fb9..5c8243d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -37,6 +37,10 @@ + + loadedAds` (keyed by slot index), `adsRemoved` -- [ ] `loadAdsForPage(int pageIndex, int postCount)` — pre-fetches `NativeAd` instances for calculated slot positions -- [ ] `disposeAd(int slotIndex)` — dispose ads scrolled far off-screen -- [ ] Skip all ad operations when `adsRemoved == true` +- [x] `AdCubit` — manages ad loading lifecycle per feed/profile instance +- [x] `AdState` — fields: slot-indexed loaded ads map, `adsRemoved` +- [x] `loadAdsForPage(int pageIndex, int postCount)` — pre-fetches ad instances for calculated slot positions +- [x] `disposeAd(int slotIndex)` — dispose ads scrolled far off-screen +- [x] Skip all ad operations when `adsRemoved == true` ### UI - Feed Ads -- [ ] `FeedLayoutView` — adjust `itemCount` to include ad slots at every `adInterval` posts -- [ ] Index mapping — `visualIndex → dataIndex` translation accounting for injected ad slots -- [ ] `AdPostCard` widget — wraps `AdWidget` + `NativeAd` in a card matching `PostCard` dimensions, "Sponsored" label -- [ ] Linear layout: full-width ad card with muted dividers -- [ ] Grid layout: ad occupies single grid cell matching card aspect ratio -- [ ] Collapse slot silently on `onAdFailedToLoad` (no blank space) +- [x] `FeedLayoutView` — adjust `itemCount` to include ad slots at every `adInterval` posts +- [x] Index mapping — `visualIndex → dataIndex` translation accounting for injected ad slots +- [x] `AdPostCard` widget — wraps ad content in a card matching `PostCard` dimensions, "Sponsored" label +- [x] Linear layout: full-width ad card with muted dividers +- [x] Grid layout: ad occupies single grid cell matching card aspect ratio +- [x] Collapse slot silently on ad load failure (no blank space) ### UI - Profile Ads -- [ ] Profile posts tab — same ad injection with `profileAdOffset = 4` (first ad appears later) -- [ ] Shared index mapping logic with feed (extract to helper or mixin) -- [ ] No ads in Replies, Media, Lists, or Starter Packs tabs +- [x] Profile posts tab — same ad injection with `profileAdOffset = 4` (first ad appears later) +- [x] Shared index mapping logic with feed (extract to helper or mixin) +- [x] No ads in Replies, Media, Lists, or Starter Packs tabs ### Tests -- [ ] Unit tests: `AdHelper` — correct ad unit IDs per platform and build mode -- [ ] Unit tests: `AdCubit` — ad loading, disposal, `adsRemoved` gating, page pre-fetch -- [ ] Unit tests: index mapping — `visualIndex ↔ dataIndex` round-trip for feed and profile offsets -- [ ] Widget tests: `AdPostCard` renders with "Sponsored" label, handles load failure gracefully -- [ ] Widget tests: feed with ads — correct post ordering, ad at expected positions, no ads when `adsRemoved` -- [ ] Widget tests: profile posts — ad offset respected, no ads in non-post tabs +- [x] Unit tests: `AdHelper` — correct ad unit IDs for the active debug platform +- [x] Unit tests: `AdCubit` — ad loading, disposal, `adsRemoved` gating, page pre-fetch +- [x] Unit tests: index mapping — `visualIndex ↔ dataIndex` round-trip for feed and profile offsets +- [x] Widget tests: `AdPostCard` renders with "Sponsored" label, slot failures collapse cleanly +- [x] Widget tests: feed with ads — correct post ordering, ad at expected positions, no ads when `adsRemoved` +- [x] Widget tests: profile posts — ad offset respected, no ads in non-post tabs ## M27 - In-App Purchase Tips ### Core - Purchase Infrastructure -- [ ] Add `in_app_purchase: ^3.2.3` to `pubspec.yaml` -- [ ] `PurchaseRepository` — wraps `InAppPurchase.instance` -- [ ] `isAvailable()` — checks store reachability -- [ ] `fetchProducts()` — `queryProductDetails({'tip_coffee', 'tip_latte'})`, returns `List` -- [ ] `buyTip(ProductDetails)` — calls `buyConsumable(purchaseParam: ...)` -- [ ] `purchaseStream` — exposes `InAppPurchase.instance.purchaseStream` -- [ ] `completePurchase(PurchaseDetails)` — forwards to `InAppPurchase.instance.completePurchase` +- [x] Add `in_app_purchase: ^3.2.3` to `pubspec.yaml` +- [x] `PurchaseRepository` — wraps `InAppPurchase.instance` +- [x] `isAvailable()` — checks store reachability +- [x] `fetchProducts()` — `queryProductDetails({'tip_coffee', 'tip_latte'})`, returns `List` +- [x] `buyTip(ProductDetails)` — calls `buyConsumable(purchaseParam: ...)` +- [x] `purchaseStream` — exposes `InAppPurchase.instance.purchaseStream` +- [x] `completePurchase(PurchaseDetails)` — forwards to `InAppPurchase.instance.completePurchase` ### Cubit -- [ ] `TipCubit` — depends on `PurchaseRepository` and `SettingsCubit` -- [ ] `TipState` — fields: `storeStatus` (loading/available/unavailable), `List products`, `purchaseStatus` (idle/pending/success/error), `adsRemoved` -- [ ] `loadProducts()` — checks availability, fetches product details -- [ ] `purchaseTip(ProductDetails)` — initiates purchase, listens for result -- [ ] On `PurchaseStatus.purchased` → call `settingsCubit.setAdsRemoved(true)`, then `completePurchase()` -- [ ] On `PurchaseStatus.error` → emit error state with message -- [ ] Subscribe to `purchaseStream` in constructor, handle all terminal states +- [x] `TipCubit` — depends on `PurchaseRepository` and `SettingsCubit` +- [x] `TipState` — fields: `storeStatus` (loading/available/unavailable), `List products`, `purchaseStatus` (idle/pending/success/error), `adsRemoved` +- [x] `loadProducts()` — checks availability, fetches product details +- [x] `purchaseTip(ProductDetails)` — initiates purchase, listens for result +- [x] On `PurchaseStatus.purchased` → call `settingsCubit.setAdsRemoved(true)`, then `completePurchase()` +- [x] On `PurchaseStatus.error` → emit error state with message +- [x] Subscribe to `purchaseStream` in constructor, handle all terminal states ### UI - Tip Sheet -- [ ] "Support Lazurite" row in Settings screen — opens modal bottom sheet -- [ ] `TipSheet` widget — header with app icon + title -- [ ] Two `ListTile` rows: Coffee (☕ $1.99) and Latte (☕☕ $4.99) with "Tip" `FilledButton` -- [ ] Localized prices from `ProductDetails.price` (not hardcoded) -- [ ] Loading state: skeleton tiles while products load -- [ ] Error state: "Store unavailable" with retry button -- [ ] If `adsRemoved`: thank-you banner above tip rows ("Ads removed — thanks for your support!") -- [ ] If `!adsRemoved`: note below rows ("Your first tip removes ads forever.") -- [ ] Pending state: loading indicator on tapped button, other button disabled +- [x] "Support Lazurite" row in Settings screen — opens modal bottom sheet +- [x] `TipSheet` widget — header with app icon + title +- [x] Two `ListTile` rows: Coffee (☕ $1.99) and Latte (☕☕ $4.99) with "Tip" `FilledButton` +- [x] Localized prices from `ProductDetails.price` (not hardcoded) +- [x] Loading state: skeleton tiles while products load +- [x] Error state: "Store unavailable" with retry button +- [x] If `adsRemoved`: thank-you banner above tip rows ("Ads removed — thanks for your support!") +- [x] If `!adsRemoved`: note below rows ("Your first tip removes ads forever.") +- [x] Pending state: loading indicator on tapped button, other button disabled ### Tests -- [ ] Unit tests: `PurchaseRepository` — product query, buy consumable, complete purchase, availability check -- [ ] Unit tests: `TipCubit` — product loading, purchase flow (success → ads removed, error → error state, pending → loading), stream subscription -- [ ] Widget tests: `TipSheet` — renders products with localized prices, loading skeleton, error + retry, thank-you banner when ads removed, note when ads not removed -- [ ] Widget tests: Settings screen — "Support Lazurite" row present, opens tip sheet on tap -- [ ] Integration: first purchase sets `adsRemoved = true` in DB, subsequent ad cubit skips loading +- [x] Unit tests: `PurchaseRepository` — product query, buy consumable, complete purchase, availability check +- [x] Unit tests: `TipCubit` — product loading, purchase flow (success → ads removed, error → error state, pending → loading), stream subscription +- [x] Widget tests: `TipSheet` — renders products with localized prices, loading skeleton, error + retry, thank-you banner when ads removed, note when ads not removed +- [x] Widget tests: Settings screen — "Support Lazurite" row present, opens tip sheet on tap +- [x] Integration: first purchase sets `adsRemoved = true` in DB, subsequent ad cubit skips loading diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 24bfae7..5dc9f9f 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -75,6 +75,17 @@ LaunchScreen UIMainStoryboardFile Main + GADApplicationIdentifier + ca-app-pub-3940256099942544~1458002511 + NSUserTrackingUsageDescription + This identifier will be used to deliver personalized ads to you. + SKAdNetworkItems + + + SKAdNetworkIdentifier + cstr6suwn9.skadnetwork + + NSPhotoLibraryAddUsageDescription Lazurite saves images and videos to your photo library when you download media. UISupportedInterfaceOrientations diff --git a/lib/core/ads/ad_helper.dart b/lib/core/ads/ad_helper.dart new file mode 100644 index 0000000..92df79c --- /dev/null +++ b/lib/core/ads/ad_helper.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart'; + +/// Static helpers for ad slot placement and index mapping. +class AdHelper { + AdHelper._(); + + /// Posts between ad slots (configurable constant). + static const int adInterval = 8; + + /// Delay before the first ad on profile posts tabs — the user came to see + /// this person's posts, so we hold off slightly longer. + static const int profileAdOffset = 4; + + static const String _iosTestAdUnitId = 'ca-app-pub-3940256099942544/3986624511'; + static const String _androidTestAdUnitId = 'ca-app-pub-3940256099942544/2247696110'; + + // TODO: replace with real production ad unit IDs before release. + static const String _iosReleaseAdUnitId = 'ca-app-pub-3940256099942544/3986624511'; + static const String _androidReleaseAdUnitId = 'ca-app-pub-3940256099942544/2247696110'; + + /// Returns the appropriate native ad unit ID for the current platform and + /// build mode. + static String get nativeAdUnitId { + final isIOS = defaultTargetPlatform == TargetPlatform.iOS; + if (kDebugMode) { + return isIOS ? _iosTestAdUnitId : _androidTestAdUnitId; + } + return isIOS ? _iosReleaseAdUnitId : _androidReleaseAdUnitId; + } + + /// Total number of visual items when ad slots are injected into [postCount] + /// posts, with the first ad deferred until [offset] posts have been shown. + /// + /// An ad slot is inserted after every [adInterval] eligible posts. + /// Posts before [offset] are not eligible. + static int visualItemCount(int postCount, {int offset = 0}) { + if (postCount == 0) return 0; + final eligible = postCount - offset; + if (eligible <= 0) return postCount; + return postCount + eligible ~/ adInterval; + } + + /// Maps a visual list index to the underlying post data index, or returns + /// `null` if the visual index is an ad slot. + /// + /// Uses a fixed period of `adInterval + 1` items per group (8 posts + 1 ad). + /// Posts before [offset] are returned as-is (no ad injection before offset). + static int? dataIndexForVisualIndex(int visualIndex, {int offset = 0}) { + if (visualIndex < offset) return visualIndex; + final relative = visualIndex - offset; + const period = adInterval + 1; + final posInPeriod = relative % period; + if (posInPeriod == adInterval) return null; + final fullPeriods = relative ~/ period; + return offset + fullPeriods * adInterval + posInPeriod; + } + + /// Returns `true` if [visualIndex] is an ad slot given the [offset]. + static bool isAdSlot(int visualIndex, {int offset = 0}) => + dataIndexForVisualIndex(visualIndex, offset: offset) == null; +} diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index 1489d2a..c8649a2 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -25,7 +25,7 @@ class AppDatabase extends _$AppDatabase { static const activeAccountDidSettingKey = 'active_account_did'; @override - int get schemaVersion => 14; + int get schemaVersion => 15; @override MigrationStrategy get migration => MigrationStrategy( @@ -101,6 +101,9 @@ class AppDatabase extends _$AppDatabase { '''); await customStatement("DELETE FROM settings WHERE key = 'feed_architecture'"); } + if (from < 15) { + await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('ads_removed', 'false')"); + } }, ); diff --git a/lib/features/ads/cubit/ad_cubit.dart b/lib/features/ads/cubit/ad_cubit.dart new file mode 100644 index 0000000..6f79e1a --- /dev/null +++ b/lib/features/ads/cubit/ad_cubit.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/ads/ad_helper.dart'; +import 'package:lazurite/features/ads/cubit/ad_state.dart'; +import 'package:lazurite/features/ads/data/native_ad_repository.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; + +class AdCubit extends Cubit { + AdCubit({required SettingsCubit settingsCubit, required NativeAdRepository nativeAdRepository}) + : _nativeAdRepository = nativeAdRepository, + super(AdState(adsRemoved: settingsCubit.state.adsRemoved)) { + _sub = settingsCubit.stream.map((SettingsState s) => s.adsRemoved).distinct().listen((adsRemoved) { + if (adsRemoved) { + clearAds(); + } + emit(state.copyWith(adsRemoved: adsRemoved)); + }); + } + + final NativeAdRepository _nativeAdRepository; + late final StreamSubscription _sub; + final Set _loadingSlots = {}; + + Future loadAdsForPage(int pageIndex, int postCount, {int offset = 0}) async { + if (state.adsRemoved || postCount <= 0) { + return; + } + + final visualCount = AdHelper.visualItemCount(postCount, offset: offset); + final slots = []; + for (var index = 0; index < visualCount; index++) { + if (AdHelper.isAdSlot(index, offset: offset)) { + slots.add(index); + } + } + + for (final slotIndex in slots) { + await loadAdSlot(slotIndex); + } + } + + Future loadAdSlot(int slotIndex) async { + if (state.adsRemoved || state.loadedAds.containsKey(slotIndex) || !_loadingSlots.add(slotIndex)) { + return; + } + + try { + final ad = await _nativeAdRepository.loadAd(slotIndex: slotIndex); + if (isClosed || ad == null || state.adsRemoved) { + ad?.dispose(); + return; + } + + emit(state.copyWith(loadedAds: {...state.loadedAds, slotIndex: ad})); + } finally { + _loadingSlots.remove(slotIndex); + } + } + + void disposeAd(int slotIndex) { + final ad = state.loadedAds[slotIndex]; + if (ad == null) { + return; + } + + final nextAds = Map.of(state.loadedAds)..remove(slotIndex); + ad.dispose(); + emit(state.copyWith(loadedAds: nextAds)); + } + + void clearAds() { + for (final ad in state.loadedAds.values) { + ad.dispose(); + } + if (state.loadedAds.isNotEmpty) { + emit(state.copyWith(loadedAds: const {})); + } + } + + @override + Future close() { + clearAds(); + _sub.cancel(); + return super.close(); + } +} diff --git a/lib/features/ads/cubit/ad_state.dart b/lib/features/ads/cubit/ad_state.dart new file mode 100644 index 0000000..84c544d --- /dev/null +++ b/lib/features/ads/cubit/ad_state.dart @@ -0,0 +1,16 @@ +import 'package:equatable/equatable.dart'; +import 'package:lazurite/features/ads/data/native_ad_repository.dart'; + +class AdState extends Equatable { + const AdState({this.loadedAds = const {}, this.adsRemoved = false}); + + final Map loadedAds; + final bool adsRemoved; + + AdState copyWith({Map? loadedAds, bool? adsRemoved}) { + return AdState(loadedAds: loadedAds ?? this.loadedAds, adsRemoved: adsRemoved ?? this.adsRemoved); + } + + @override + List get props => [loadedAds, adsRemoved]; +} diff --git a/lib/features/ads/data/native_ad_repository.dart b/lib/features/ads/data/native_ad_repository.dart new file mode 100644 index 0000000..9eb510e --- /dev/null +++ b/lib/features/ads/data/native_ad_repository.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; +import 'package:lazurite/core/ads/ad_helper.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; + +abstract class NativeAdHandle { + Widget buildWidget(); + + void dispose(); +} + +abstract class NativeAdRepository { + Future loadAd({required int slotIndex}); +} + +class GoogleMobileNativeAdRepository implements NativeAdRepository { + @override + Future loadAd({required int slotIndex}) async { + final completer = Completer(); + + try { + late final NativeAd ad; + ad = NativeAd( + adUnitId: AdHelper.nativeAdUnitId, + request: const AdRequest(), + nativeTemplateStyle: NativeTemplateStyle(templateType: TemplateType.medium), + listener: NativeAdListener( + onAdLoaded: (_) { + if (!completer.isCompleted) { + completer.complete(_GoogleMobileNativeAdHandle(ad)); + } + }, + onAdFailedToLoad: (failedAd, error) { + failedAd.dispose(); + log.d('Native ad failed to load for slot $slotIndex: $error'); + if (!completer.isCompleted) { + completer.complete(null); + } + }, + ), + ); + + await ad.load(); + return completer.future; + } catch (error, stackTrace) { + log.e('Failed to request native ad for slot $slotIndex', error: error, stackTrace: stackTrace); + if (!completer.isCompleted) { + completer.complete(null); + } + return completer.future; + } + } +} + +class _GoogleMobileNativeAdHandle implements NativeAdHandle { + _GoogleMobileNativeAdHandle(this._ad); + + final NativeAd _ad; + + @override + Widget buildWidget() => AdWidget(ad: _ad); + + @override + void dispose() => _ad.dispose(); +} diff --git a/lib/features/ads/presentation/ad_post_card.dart b/lib/features/ads/presentation/ad_post_card.dart new file mode 100644 index 0000000..dd10212 --- /dev/null +++ b/lib/features/ads/presentation/ad_post_card.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +class AdPostCard extends StatelessWidget { + const AdPostCard({required this.child, this.isLinear = false, super.key}); + + final bool isLinear; + final Widget child; + + @override + Widget build(BuildContext context) { + return isLinear ? _buildLinear(context) : _buildGrid(context); + } + + Widget _buildLinear(BuildContext context) { + final theme = Theme.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Sponsored', + style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + ), + ConstrainedBox( + constraints: const BoxConstraints(minWidth: double.infinity, minHeight: 80, maxHeight: 200), + child: child, + ), + const Divider(height: 1), + ], + ); + } + + Widget _buildGrid(BuildContext context) { + final theme = Theme.of(context); + return Stack( + children: [ + Positioned.fill(child: child), + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Sponsored', + style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/ads/presentation/ad_slot.dart b/lib/features/ads/presentation/ad_slot.dart new file mode 100644 index 0000000..22a4c6a --- /dev/null +++ b/lib/features/ads/presentation/ad_slot.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/ads/cubit/ad_cubit.dart'; +import 'package:lazurite/features/ads/cubit/ad_state.dart'; +import 'package:lazurite/features/ads/presentation/ad_post_card.dart'; + +class AdSlot extends StatefulWidget { + const AdSlot({required this.slotIndex, this.isLinear = false, super.key}); + + final int slotIndex; + final bool isLinear; + + @override + State createState() => _AdSlotState(); +} + +class _AdSlotState extends State { + late final AdCubit _adCubit; + + @override + void initState() { + super.initState(); + _adCubit = context.read(); + _requestAd(); + } + + @override + void didUpdateWidget(covariant AdSlot oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.slotIndex != widget.slotIndex) { + _requestAd(); + } + } + + @override + void dispose() { + _adCubit.disposeAd(widget.slotIndex); + super.dispose(); + } + + void _requestAd() { + if (_adCubit.state.adsRemoved) { + return; + } + unawaited(_adCubit.loadAdSlot(widget.slotIndex)); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => + previous.adsRemoved != current.adsRemoved || + previous.loadedAds[widget.slotIndex] != current.loadedAds[widget.slotIndex], + builder: (context, state) { + if (state.adsRemoved) { + return const SizedBox.shrink(); + } + + final ad = state.loadedAds[widget.slotIndex]; + if (ad == null) { + return const SizedBox.shrink(); + } + + return AdPostCard(isLinear: widget.isLinear, child: ad.buildWidget()); + }, + ); + } +} diff --git a/lib/features/feed/presentation/home_feed_screen.dart b/lib/features/feed/presentation/home_feed_screen.dart index 1493568..d9434d4 100644 --- a/lib/features/feed/presentation/home_feed_screen.dart +++ b/lib/features/feed/presentation/home_feed_screen.dart @@ -5,6 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; +import 'package:lazurite/features/ads/cubit/ad_cubit.dart'; +import 'package:lazurite/features/ads/data/native_ad_repository.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; @@ -12,6 +14,7 @@ import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; import 'package:lazurite/features/feed/presentation/widgets/feed_layout_view.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; /// Returns the number of grid columns for [width] per the responsive /// breakpoints defined in the UI spec. @@ -237,6 +240,7 @@ class _FeedListView extends StatefulWidget { class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveClientMixin { final List _posts = []; + late final AdCubit _adCubit; String? _cursor; bool _isLoading = false; bool _showInitialLoading = false; @@ -251,6 +255,10 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli @override void initState() { super.initState(); + _adCubit = AdCubit( + settingsCubit: context.read(), + nativeAdRepository: context.read(), + ); _scrollController.addListener(_onScroll); _primeFeed(); } @@ -259,6 +267,7 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli void dispose() { _scrollController.removeListener(_onScroll); _scrollController.dispose(); + _adCubit.close(); super.dispose(); } @@ -323,6 +332,7 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli _showInitialLoading = false; _hasError = false; }); + _prefetchAds(); } catch (e) { if (_posts.isNotEmpty) { _setStateIfMounted(() { @@ -362,6 +372,7 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli _cursor = result.cursor; _isLoadingMore = false; }); + _prefetchAds(); } catch (e) { _setStateIfMounted(() => _isLoadingMore = false); } @@ -375,6 +386,19 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli setState(fn); } + void _prefetchAds() { + if (_posts.isEmpty || !mounted) { + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _adCubit.loadAdsForPage(0, _posts.length); + }); + } + Future _fetchFeed(FeedRepository repo, {String? cursor}) async { final feedType = widget.feed.type; if (feedType is SavedFeedTypeKnownValue) { @@ -433,13 +457,16 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli ); } - return FeedLayoutView( - itemCount: _posts.length, - scrollController: _scrollController, - isLoadingMore: _isLoadingMore, - onRefresh: _loadFeed, - gridItemBuilder: (context, index) => buildCard(index, PostCardVariant.grid), - linearItemBuilder: (context, index) => buildCard(index, PostCardVariant.linear), + return BlocProvider.value( + value: _adCubit, + child: FeedLayoutView( + itemCount: _posts.length, + scrollController: _scrollController, + isLoadingMore: _isLoadingMore, + onRefresh: _loadFeed, + gridItemBuilder: (context, index) => buildCard(index, PostCardVariant.grid), + linearItemBuilder: (context, index) => buildCard(index, PostCardVariant.linear), + ), ); } } diff --git a/lib/features/feed/presentation/widgets/feed_layout_view.dart b/lib/features/feed/presentation/widgets/feed_layout_view.dart index 0a76152..62e1c26 100644 --- a/lib/features/feed/presentation/widgets/feed_layout_view.dart +++ b/lib/features/feed/presentation/widgets/feed_layout_view.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/ads/ad_helper.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; +import 'package:lazurite/features/ads/presentation/ad_slot.dart'; import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; @@ -15,6 +17,9 @@ const double _gridCardChromeHeight = 160; /// [gridItemBuilder] is used when the card layout is active. /// [linearItemBuilder] is used when the compact layout is active. /// This allows the caller to render the appropriate card variant for each mode. +/// +/// When [adsRemoved] is false on [SettingsCubit], native ad slots are injected +/// automatically every [AdHelper.adInterval] posts, deferred by [adOffset]. class FeedLayoutView extends StatelessWidget { const FeedLayoutView({ super.key, @@ -24,6 +29,7 @@ class FeedLayoutView extends StatelessWidget { required this.scrollController, required this.isLoadingMore, required this.onRefresh, + this.adOffset = 0, }); final int itemCount; @@ -33,24 +39,48 @@ class FeedLayoutView extends StatelessWidget { final bool isLoadingMore; final RefreshCallback onRefresh; + /// Visual items before the first ad slot. Set to [AdHelper.profileAdOffset] + /// + /// For profile post tabs, leave at 0 for feeds. + final int adOffset; + @override Widget build(BuildContext context) { return BlocBuilder( - buildWhen: (prev, curr) => prev.feedLayout != curr.feedLayout, + buildWhen: (prev, curr) => prev.feedLayout != curr.feedLayout || prev.adsRemoved != curr.adsRemoved, builder: (context, settingsState) { + final adsRemoved = settingsState.adsRemoved; + final effectiveCount = adsRemoved ? itemCount : AdHelper.visualItemCount(itemCount, offset: adOffset); + + final wrappedGrid = adsRemoved + ? gridItemBuilder + : (ctx, vi) { + final di = AdHelper.dataIndexForVisualIndex(vi, offset: adOffset); + return di != null ? gridItemBuilder(ctx, di) : AdSlot(key: ValueKey('ad_slot_$vi'), slotIndex: vi); + }; + + final wrappedLinear = adsRemoved + ? linearItemBuilder + : (ctx, vi) { + final di = AdHelper.dataIndexForVisualIndex(vi, offset: adOffset); + return di != null + ? linearItemBuilder(ctx, di) + : AdSlot(key: ValueKey('ad_slot_$vi'), slotIndex: vi, isLinear: true); + }; + if (settingsState.feedLayout == FeedLayout.card) { - return _buildGrid(context); + return _buildGrid(context, effectiveCount, wrappedGrid); } - return _buildLinear(context); + return _buildLinear(context, effectiveCount, wrappedLinear); }, ); } - Widget _buildGrid(BuildContext context) { + Widget _buildGrid(BuildContext context, int count, IndexedWidgetBuilder builder) { final width = MediaQuery.of(context).size.width; final columns = feedColumnCount(width); if (columns == 1) { - return _buildSingleColumnGrid(context); + return _buildSingleColumnGrid(context, count, builder); } final tileWidth = (width - ((columns - 1) * _gridSpacing)) / columns; @@ -60,7 +90,7 @@ class FeedLayoutView extends StatelessWidget { controller: scrollController, slivers: [ SliverGrid( - delegate: SliverChildBuilderDelegate(gridItemBuilder, childCount: itemCount), + delegate: SliverChildBuilderDelegate(builder, childCount: count), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: columns, crossAxisSpacing: _gridSpacing, @@ -79,7 +109,7 @@ class FeedLayoutView extends StatelessWidget { ); } - Widget _buildSingleColumnGrid(BuildContext context) { + Widget _buildSingleColumnGrid(BuildContext context, int count, IndexedWidgetBuilder builder) { return RefreshIndicator( onRefresh: onRefresh, child: CustomScrollView( @@ -88,8 +118,8 @@ class FeedLayoutView extends StatelessWidget { SliverPadding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), sliver: SliverList.separated( - itemCount: itemCount, - itemBuilder: gridItemBuilder, + itemCount: count, + itemBuilder: builder, separatorBuilder: (_, _) => const SizedBox(height: 12), ), ), @@ -104,20 +134,20 @@ class FeedLayoutView extends StatelessWidget { ); } - Widget _buildLinear(BuildContext context) { + Widget _buildLinear(BuildContext context, int count, IndexedWidgetBuilder builder) { return RefreshIndicator( onRefresh: onRefresh, child: ListView.builder( controller: scrollController, padding: const EdgeInsets.symmetric(vertical: 4), - itemCount: itemCount + (isLoadingMore ? 1 : 0), + itemCount: count + (isLoadingMore ? 1 : 0), itemBuilder: (context, index) { - if (index == itemCount) { + if (index == count) { return const Center( child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), ); } - return Padding(padding: const EdgeInsets.only(bottom: 4), child: linearItemBuilder(context, index)); + return Padding(padding: const EdgeInsets.only(bottom: 4), child: builder(context, index)); }, ), ); diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 8d01da3..50d6c3a 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -6,9 +6,13 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; +import 'package:lazurite/core/ads/ad_helper.dart'; import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/core/widgets/sliver_tab_bar_delegate.dart'; +import 'package:lazurite/features/ads/cubit/ad_cubit.dart'; +import 'package:lazurite/features/ads/data/native_ad_repository.dart'; +import 'package:lazurite/features/ads/presentation/ad_slot.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; @@ -83,10 +87,17 @@ class _ProfileScreenState extends State with SingleTickerProvider late TabController _tabController; late bool _showSuggestedTab; + late final AdCubit _adCubit; + int? _lastAdPostCount; + bool _lastInjectedAds = false; @override void initState() { super.initState(); + _adCubit = AdCubit( + settingsCubit: context.read(), + nativeAdRepository: context.read(), + ); _showSuggestedTab = _shouldShowSuggestedTab(context.read().state.profile); _tabController = TabController(length: _tabLabels.length, vsync: this); _loadProfileAndFeed(); @@ -104,6 +115,7 @@ class _ProfileScreenState extends State with SingleTickerProvider @override void dispose() { + _adCubit.close(); _tabController.dispose(); super.dispose(); } @@ -744,19 +756,32 @@ class _ProfileScreenState extends State with SingleTickerProvider return Center(child: Text(_emptyLabel(tabFilter))); } + final showAds = tabFilter == FeedFilter.postsNoReplies; + return BlocBuilder( - buildWhen: (prev, curr) => prev.feedLayout != curr.feedLayout, + buildWhen: (prev, curr) => prev.feedLayout != curr.feedLayout || prev.adsRemoved != curr.adsRemoved, builder: (context, settingsState) { + final injectAds = showAds && !settingsState.adsRemoved; + _syncAds(feedState.posts.length, injectAds: injectAds); if (settingsState.feedLayout == FeedLayout.card) { - return _buildGridFeed(context, feedState); + return BlocProvider.value( + value: _adCubit, + child: _buildGridFeed(context, feedState, injectAds: injectAds), + ); } - return _buildLinearFeed(context, feedState); + return BlocProvider.value( + value: _adCubit, + child: _buildLinearFeed(context, feedState, injectAds: injectAds), + ); }, ); } - Widget _buildGridFeed(BuildContext context, FeedState feedState) { + Widget _buildGridFeed(BuildContext context, FeedState feedState, {bool injectAds = false}) { final accountDid = _resolvedActor ?? ''; + final visualCount = injectAds + ? AdHelper.visualItemCount(feedState.posts.length, offset: AdHelper.profileAdOffset) + : feedState.posts.length; return RefreshIndicator( onRefresh: _refresh, @@ -772,21 +797,37 @@ class _ProfileScreenState extends State with SingleTickerProvider child: ListView.builder( key: const ValueKey('profile_grid_feed'), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), + itemCount: visualCount + (feedState.isLoadingMore ? 1 : 0), itemBuilder: (context, index) { - if (index >= feedState.posts.length) { + if (index >= visualCount) { return const Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()), ); } - final post = feedState.posts[index]; + + final dataIndex = injectAds + ? AdHelper.dataIndexForVisualIndex(index, offset: AdHelper.profileAdOffset) + : index; + if (dataIndex == null) { + return Padding( + padding: EdgeInsets.only(bottom: index == visualCount - 1 ? 0 : 16), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: AdSlot(key: ValueKey('ad_slot_$index'), slotIndex: index), + ), + ), + ); + } + + final post = feedState.posts[dataIndex]; return Padding( - padding: EdgeInsets.only(bottom: index == feedState.posts.length - 1 ? 0 : 16), + padding: EdgeInsets.only(bottom: index == visualCount - 1 ? 0 : 16), child: Center( child: ConstrainedBox( - key: ValueKey('profile_large_card_$index'), + key: ValueKey('profile_large_card_$dataIndex'), constraints: const BoxConstraints(maxWidth: 720), child: PostCardWithActions( feedViewPost: post, @@ -803,8 +844,11 @@ class _ProfileScreenState extends State with SingleTickerProvider ); } - Widget _buildLinearFeed(BuildContext context, FeedState feedState) { + Widget _buildLinearFeed(BuildContext context, FeedState feedState, {bool injectAds = false}) { final accountDid = _resolvedActor ?? ''; + final visualCount = injectAds + ? AdHelper.visualItemCount(feedState.posts.length, offset: AdHelper.profileAdOffset) + : feedState.posts.length; return RefreshIndicator( onRefresh: _refresh, child: NotificationListener( @@ -818,16 +862,24 @@ class _ProfileScreenState extends State with SingleTickerProvider }, child: ListView.builder( padding: EdgeInsets.zero, - itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), + itemCount: visualCount + (feedState.isLoadingMore ? 1 : 0), itemBuilder: (context, index) { - if (index >= feedState.posts.length) { + if (index >= visualCount) { return const Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()), ); } + + final dataIndex = injectAds + ? AdHelper.dataIndexForVisualIndex(index, offset: AdHelper.profileAdOffset) + : index; + if (dataIndex == null) { + return AdSlot(key: ValueKey('ad_slot_$index'), slotIndex: index, isLinear: true); + } + return PostCardWithActions( - feedViewPost: feedState.posts[index], + feedViewPost: feedState.posts[dataIndex], accountDid: accountDid, moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, ); @@ -865,6 +917,30 @@ class _ProfileScreenState extends State with SingleTickerProvider return _ProfileStarterPacksPane(actor: actor, starterPackRepository: starterPackRepository); } + void _syncAds(int postCount, {required bool injectAds}) { + if (!injectAds) { + if (_lastInjectedAds) { + _adCubit.clearAds(); + } + _lastInjectedAds = false; + _lastAdPostCount = null; + return; + } + + if (_lastInjectedAds && _lastAdPostCount == postCount) { + return; + } + + _lastInjectedAds = true; + _lastAdPostCount = postCount; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _adCubit.loadAdsForPage(0, postCount, offset: AdHelper.profileAdOffset); + }); + } + String _emptyLabel(FeedFilter filter) { switch (filter) { case FeedFilter.postsNoReplies: diff --git a/lib/features/settings/bloc/settings_cubit.dart b/lib/features/settings/bloc/settings_cubit.dart index 58fddc8..bf397fd 100644 --- a/lib/features/settings/bloc/settings_cubit.dart +++ b/lib/features/settings/bloc/settings_cubit.dart @@ -37,6 +37,7 @@ class SettingsCubit extends Cubit { static const String _keyThreadAutoCollapseDepth = 'thread_auto_collapse_depth'; static const String _keyConstellationUrl = 'constellation_url'; static const String _defaultConstellationUrl = 'https://constellation.microcosm.blue'; + static const String _keyAdsRemoved = 'ads_removed'; Future loadSettings() async { final paletteStr = await database.getSetting(_keyThemePalette); @@ -47,6 +48,7 @@ class SettingsCubit extends Cubit { final simulateOfflineStr = await database.getSetting(_keySimulateOffline); final threadAutoCollapseDepthStr = await database.getSetting(_keyThreadAutoCollapseDepth); final constellationUrlStr = await database.getSetting(_keyConstellationUrl); + final adsRemovedStr = await database.getSetting(_keyAdsRemoved); emit( state.copyWith( @@ -57,6 +59,7 @@ class SettingsCubit extends Cubit { simulateOffline: simulateOfflineStr == 'true', threadAutoCollapseDepth: int.tryParse(threadAutoCollapseDepthStr ?? ''), constellationUrl: constellationUrlStr ?? _defaultConstellationUrl, + adsRemoved: adsRemovedStr == 'true', ), ); } @@ -106,4 +109,9 @@ class SettingsCubit extends Cubit { await database.setSetting(_keyConstellationUrl, url); emit(state.copyWith(constellationUrl: url)); } + + Future setAdsRemoved(bool value) async { + await database.setSetting(_keyAdsRemoved, value.toString()); + emit(state.copyWith(adsRemoved: value)); + } } diff --git a/lib/features/settings/bloc/settings_state.dart b/lib/features/settings/bloc/settings_state.dart index c36697c..fa6a061 100644 --- a/lib/features/settings/bloc/settings_state.dart +++ b/lib/features/settings/bloc/settings_state.dart @@ -13,6 +13,7 @@ class SettingsState extends Equatable { this.simulateOffline = false, this.threadAutoCollapseDepth, this.constellationUrl = 'https://constellation.microcosm.blue', + this.adsRemoved = false, }); final AppThemePalette themePalette; @@ -22,6 +23,7 @@ class SettingsState extends Equatable { final bool simulateOffline; final int? threadAutoCollapseDepth; final String constellationUrl; + final bool adsRemoved; SettingsState copyWith({ AppThemePalette? themePalette, @@ -31,6 +33,7 @@ class SettingsState extends Equatable { bool? simulateOffline, Object? threadAutoCollapseDepth = _threadAutoCollapseDepthUnset, String? constellationUrl, + bool? adsRemoved, }) { return SettingsState( themePalette: themePalette ?? this.themePalette, @@ -42,6 +45,7 @@ class SettingsState extends Equatable { ? this.threadAutoCollapseDepth : threadAutoCollapseDepth as int?, constellationUrl: constellationUrl ?? this.constellationUrl, + adsRemoved: adsRemoved ?? this.adsRemoved, ); } @@ -54,5 +58,6 @@ class SettingsState extends Equatable { simulateOffline, threadAutoCollapseDepth, constellationUrl, + adsRemoved, ]; } diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index e27b56f..96f927d 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -13,6 +13,7 @@ import 'package:lazurite/features/moderation/data/moderation_service.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; +import 'package:lazurite/features/tips/presentation/tip_sheet.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -92,6 +93,12 @@ class SettingsScreen extends StatelessWidget { subtitle: 'Check your daily video quota', onTap: () => context.push('/settings/video-limits'), ), + _SettingsTile( + icon: Icons.favorite_outline, + title: 'Support Lazurite', + subtitle: 'Buy us a coffee — removes ads forever', + onTap: () => showTipSheet(context), + ), const SizedBox(height: 24), _buildSectionHeader(context, 'Advanced'), _buildAdvancedSettings(context), diff --git a/lib/features/tips/cubit/tip_cubit.dart b/lib/features/tips/cubit/tip_cubit.dart new file mode 100644 index 0000000..51ce75b --- /dev/null +++ b/lib/features/tips/cubit/tip_cubit.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/tips/cubit/tip_state.dart'; +import 'package:lazurite/features/tips/data/purchase_repository.dart'; + +class TipCubit extends Cubit { + TipCubit({required PurchaseRepository purchaseRepository, required SettingsCubit settingsCubit}) + : _repo = purchaseRepository, + _settings = settingsCubit, + super(TipState(adsRemoved: settingsCubit.state.adsRemoved)) { + _purchaseSub = purchaseRepository.purchaseStream.listen(_onPurchaseUpdate); + } + + final PurchaseRepository _repo; + final SettingsCubit _settings; + late final StreamSubscription> _purchaseSub; + + /// Checks store availability and fetches product details. + Future loadProducts() async { + emit(state.copyWith(storeStatus: TipStoreStatus.loading)); + try { + final available = await _repo.isAvailable(); + if (!available) { + emit(state.copyWith(storeStatus: TipStoreStatus.unavailable)); + return; + } + final products = await _repo.fetchProducts(); + emit(state.copyWith(storeStatus: TipStoreStatus.available, products: products)); + } catch (e) { + emit(state.copyWith(storeStatus: TipStoreStatus.unavailable)); + } + } + + /// Initiates a tip purchase. + Future purchaseTip(ProductDetails product) async { + emit(state.copyWith(purchaseStatus: TipPurchaseStatus.pending, clearError: true)); + try { + await _repo.buyTip(product); + } catch (e) { + emit(state.copyWith(purchaseStatus: TipPurchaseStatus.error, errorMessage: e.toString())); + } + } + + Future _onPurchaseUpdate(List purchases) async { + for (final purchase in purchases) { + switch (purchase.status) { + case PurchaseStatus.pending: + emit(state.copyWith(purchaseStatus: TipPurchaseStatus.pending, clearError: true)); + break; + case PurchaseStatus.purchased: + if (!state.adsRemoved) { + await _settings.setAdsRemoved(true); + emit(state.copyWith(adsRemoved: true)); + } + emit(state.copyWith(purchaseStatus: TipPurchaseStatus.success, clearError: true)); + if (purchase.pendingCompletePurchase) { + await _repo.completePurchase(purchase); + } + break; + case PurchaseStatus.error: + emit(state.copyWith(purchaseStatus: TipPurchaseStatus.error, errorMessage: purchase.error?.message)); + if (purchase.pendingCompletePurchase) { + await _repo.completePurchase(purchase); + } + break; + case PurchaseStatus.restored: + if (purchase.pendingCompletePurchase) { + await _repo.completePurchase(purchase); + } + break; + case PurchaseStatus.canceled: + emit(state.copyWith(purchaseStatus: TipPurchaseStatus.idle, clearError: true)); + break; + } + } + } + + @override + Future close() { + _purchaseSub.cancel(); + return super.close(); + } +} diff --git a/lib/features/tips/cubit/tip_state.dart b/lib/features/tips/cubit/tip_state.dart new file mode 100644 index 0000000..dd7e7a0 --- /dev/null +++ b/lib/features/tips/cubit/tip_state.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; + +enum TipStoreStatus { loading, available, unavailable } + +enum TipPurchaseStatus { idle, pending, success, error } + +class TipState extends Equatable { + const TipState({ + this.storeStatus = TipStoreStatus.loading, + this.products = const [], + this.purchaseStatus = TipPurchaseStatus.idle, + this.errorMessage, + this.adsRemoved = false, + }); + + final TipStoreStatus storeStatus; + final List products; + final TipPurchaseStatus purchaseStatus; + final String? errorMessage; + final bool adsRemoved; + + TipState copyWith({ + TipStoreStatus? storeStatus, + List? products, + TipPurchaseStatus? purchaseStatus, + String? errorMessage, + bool? adsRemoved, + bool clearError = false, + }) { + return TipState( + storeStatus: storeStatus ?? this.storeStatus, + products: products ?? this.products, + purchaseStatus: purchaseStatus ?? this.purchaseStatus, + errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), + adsRemoved: adsRemoved ?? this.adsRemoved, + ); + } + + @override + List get props => [storeStatus, products, purchaseStatus, errorMessage, adsRemoved]; +} diff --git a/lib/features/tips/data/purchase_repository.dart b/lib/features/tips/data/purchase_repository.dart new file mode 100644 index 0000000..1b6a131 --- /dev/null +++ b/lib/features/tips/data/purchase_repository.dart @@ -0,0 +1,54 @@ +import 'package:in_app_purchase/in_app_purchase.dart'; + +/// Abstract purchase repository — wraps [InAppPurchase.instance] so it can be +/// mocked in unit tests. +abstract class PurchaseRepository { + /// Product IDs for the two tip tiers. + static const String coffeeProductId = 'tip_coffee'; + static const String latteProductId = 'tip_latte'; + static const Set productIds = {coffeeProductId, latteProductId}; + + /// Returns true if the underlying store is available and can process + /// purchases. + Future isAvailable(); + + /// Fetches [ProductDetails] for both tip products from the store. + Future> fetchProducts(); + + /// Initiates a consumable purchase for [product]. + Future buyTip(ProductDetails product); + + /// Broadcast stream of purchase updates. + Stream> get purchaseStream; + + /// Must be called after verifying and delivering every terminal purchase. + Future completePurchase(PurchaseDetails details); +} + +/// Production implementation backed by [InAppPurchase.instance]. +class InAppPurchaseRepository implements PurchaseRepository { + InAppPurchaseRepository({InAppPurchase? iap}) : _iap = iap ?? InAppPurchase.instance; + + final InAppPurchase _iap; + + @override + Future isAvailable() => _iap.isAvailable(); + + @override + Future> fetchProducts() async { + final response = await _iap.queryProductDetails(PurchaseRepository.productIds); + return response.productDetails; + } + + @override + Future buyTip(ProductDetails product) async { + final param = PurchaseParam(productDetails: product); + await _iap.buyConsumable(purchaseParam: param); + } + + @override + Stream> get purchaseStream => _iap.purchaseStream; + + @override + Future completePurchase(PurchaseDetails details) => _iap.completePurchase(details); +} diff --git a/lib/features/tips/presentation/tip_sheet.dart b/lib/features/tips/presentation/tip_sheet.dart new file mode 100644 index 0000000..8222e0c --- /dev/null +++ b/lib/features/tips/presentation/tip_sheet.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/tips/cubit/tip_cubit.dart'; +import 'package:lazurite/features/tips/cubit/tip_state.dart'; +import 'package:lazurite/features/tips/data/purchase_repository.dart'; + +/// Opens the [TipSheet] as a modal bottom sheet. +void showTipSheet(BuildContext context) { + final purchaseRepo = context.read(); + final settingsCubit = context.read(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) => BlocProvider( + create: (_) => TipCubit(purchaseRepository: purchaseRepo, settingsCubit: settingsCubit)..loadProducts(), + child: const TipSheet(), + ), + ); +} + +/// Modal bottom sheet for in-app tip purchases. +class TipSheet extends StatelessWidget { + const TipSheet({super.key}); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 16), + child: BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + const SizedBox(height: 24), + if (state.adsRemoved) _buildAdsRemovedBanner(context), + _buildContent(context, state), + if (!state.adsRemoved) ...[const SizedBox(height: 12), _buildAdsNote(context)], + ], + ); + }, + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.favorite, color: Theme.of(context).colorScheme.onPrimaryContainer), + ), + const SizedBox(width: 12), + Text('Support Lazurite', style: Theme.of(context).textTheme.titleLarge), + ], + ); + } + + Widget _buildAdsRemovedBanner(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.check_circle_outline, color: Theme.of(context).colorScheme.onPrimaryContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Ads removed — thanks for your support!', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onPrimaryContainer), + ), + ), + ], + ), + ); + } + + Widget _buildContent(BuildContext context, TipState state) { + return switch (state.storeStatus) { + TipStoreStatus.loading => _buildSkeleton(), + TipStoreStatus.unavailable => _buildUnavailable(context), + TipStoreStatus.available => _buildProducts(context, state), + }; + } + + Widget _buildSkeleton() { + return const Column( + children: [ + _SkeletonTile(key: Key('tip_skeleton_0')), + SizedBox(height: 8), + _SkeletonTile(key: Key('tip_skeleton_1')), + ], + ); + } + + Widget _buildUnavailable(BuildContext context) { + return Column( + children: [ + const Icon(Icons.store_outlined, size: 48), + const SizedBox(height: 8), + Text('Store unavailable', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text( + 'Please check your connection and try again.', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton(onPressed: () => context.read().loadProducts(), child: const Text('Retry')), + ], + ); + } + + /// Builds the canonical list: coffee first, latte second. + Widget _buildProducts(BuildContext context, TipState state) { + final isPending = state.purchaseStatus == TipPurchaseStatus.pending; + + final ordered = <(String emoji, ProductDetails product)>[]; + ProductDetails? coffee; + ProductDetails? latte; + for (final p in state.products) { + if (p.id == PurchaseRepository.coffeeProductId) coffee = p; + if (p.id == PurchaseRepository.latteProductId) latte = p; + } + if (coffee != null) ordered.add(('☕', coffee)); + if (latte != null) ordered.add(('☕☕', latte)); + + if (ordered.isEmpty) { + return Center(child: Text('No products available', style: Theme.of(context).textTheme.bodyMedium)); + } + + return Column( + children: [ + for (final (emoji, product) in ordered) ...[ + _TipProductTile( + emoji: emoji, + product: product, + isPending: isPending, + onTap: isPending ? null : () => context.read().purchaseTip(product), + ), + const SizedBox(height: 8), + ], + if (state.purchaseStatus == TipPurchaseStatus.error && state.errorMessage != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + state.errorMessage!, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ); + } + + Widget _buildAdsNote(BuildContext context) { + return Text( + 'Your first tip removes ads forever.', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ); + } +} + +class _TipProductTile extends StatelessWidget { + const _TipProductTile({required this.emoji, required this.product, required this.isPending, required this.onTap}); + + final String emoji; + final ProductDetails product; + final bool isPending; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Text(emoji, style: const TextStyle(fontSize: 24)), + title: Text(product.title), + subtitle: Text(product.description), + trailing: isPending + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) + : FilledButton(onPressed: onTap, child: Text(product.price)), + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + ); + } +} + +class _SkeletonTile extends StatelessWidget { + const _SkeletonTile({super.key}); + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.surfaceContainerHighest; + return Row( + children: [ + Container(width: 40, height: 40, color: color), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(width: 120, height: 14, color: color), + const SizedBox(height: 6), + Container(width: 80, height: 12, color: color), + ], + ), + ), + Container(width: 60, height: 32, color: color), + ], + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index d093946..6f90b8e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:bluesky/bluesky_chat.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/logging/logging_bloc_observer.dart'; @@ -14,6 +15,7 @@ import 'package:lazurite/core/router/app_router.dart'; import 'package:lazurite/core/scheduler/post_scheduler.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; +import 'package:lazurite/features/ads/data/native_ad_repository.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/auth_repository.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; @@ -40,6 +42,7 @@ import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:lazurite/features/settings/data/video_repository.dart'; import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; +import 'package:lazurite/features/tips/data/purchase_repository.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -60,6 +63,11 @@ Future main() async { final settingsCubit = SettingsCubit(database: database); await settingsCubit.loadSettings(); + + if (!settingsCubit.state.adsRemoved) { + await MobileAds.instance.initialize(); + } + final connectivityCubit = ConnectivityCubit(simulateOffline: settingsCubit.state.simulateOffline); final accountSwitcherCubit = AccountSwitcherCubit(database: database, authRepository: authRepository); @@ -260,6 +268,8 @@ class _LazuriteAppState extends State { RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), RepositoryProvider(create: (_) => PostActionCache()), RepositoryProvider(create: (_) => VideoRepository(bluesky: bluesky)), + RepositoryProvider(create: (_) => GoogleMobileNativeAdRepository()), + RepositoryProvider(create: (_) => InAppPurchaseRepository()), RepositoryProvider.value(value: bluesky), RepositoryProvider.value(value: widget.database), RepositoryProvider.value(value: accountDid), diff --git a/pubspec.lock b/pubspec.lock index 0494aad..7f731a8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -560,6 +560,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.3.3" + google_mobile_ads: + dependency: "direct main" + description: + name: google_mobile_ads + sha256: f35e040875bb54e8a3455bcffed3b4ac9e9263fbf7751b9fd1ae7f30793faee8 + url: "https://pub.dev" + source: hosted + version: "7.0.0" graphs: dependency: transitive description: @@ -672,6 +680,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + in_app_purchase: + dependency: "direct main" + description: + name: in_app_purchase + sha256: "5cddd7f463f3bddb1d37a72b95066e840d5822d66291331d7f8f05ce32c24b6c" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + in_app_purchase_android: + dependency: transitive + description: + name: in_app_purchase_android + sha256: "634bee4734b17fe55f370f0ac07a22431a9666e0f3a870c6d20350856e8bbf71" + url: "https://pub.dev" + source: hosted + version: "0.4.0+10" + in_app_purchase_platform_interface: + dependency: transitive + description: + name: in_app_purchase_platform_interface + sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + in_app_purchase_storekit: + dependency: transitive + description: + name: in_app_purchase_storekit + sha256: "1d512809edd9f12ff88fce4596a13a18134e2499013f4d6a8894b04699363c93" + url: "https://pub.dev" + source: hosted + version: "0.4.8+1" intl: dependency: "direct main" description: @@ -1453,6 +1493,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 + url: "https://pub.dev" + source: hosted + version: "4.13.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "0f7fcd2c86bf36bdcf94881f7941ce0cbc4f8d104b9fdcd5fcbef90e2199db76" + url: "https://pub.dev" + source: hosted + version: "4.10.15" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: d7219cfabc6f5fc2032e0fa980ec36d71f308a35a823395af1abc34d9a2ede83 + url: "https://pub.dev" + source: hosted + version: "3.24.2" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8bd2176..ed9a9d1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,8 @@ dependencies: gal: ^2.3.2 permission_handler: ^12.0.1 provider: ^6.1.5+1 + google_mobile_ads: ^7.0.0 + in_app_purchase: ^3.2.3 dev_dependencies: flutter_test: diff --git a/test/core/ads/ad_helper_test.dart b/test/core/ads/ad_helper_test.dart new file mode 100644 index 0000000..fd3c1c0 --- /dev/null +++ b/test/core/ads/ad_helper_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/ads/ad_helper.dart'; + +void main() { + group('AdHelper', () { + test('uses the Google test unit ID for the active debug platform', () { + final expected = switch (defaultTargetPlatform) { + TargetPlatform.iOS => 'ca-app-pub-3940256099942544/3986624511', + _ => 'ca-app-pub-3940256099942544/2247696110', + }; + + expect(AdHelper.nativeAdUnitId, expected); + }); + + test('visual item count injects ads every eight posts', () { + expect(AdHelper.visualItemCount(0), 0); + expect(AdHelper.visualItemCount(8), 9); + expect(AdHelper.visualItemCount(10), 11); + expect(AdHelper.visualItemCount(12, offset: AdHelper.profileAdOffset), 13); + }); + + test('maps feed visual indices back to post indices', () { + expect(AdHelper.dataIndexForVisualIndex(0), 0); + expect(AdHelper.dataIndexForVisualIndex(7), 7); + expect(AdHelper.dataIndexForVisualIndex(8), isNull); + expect(AdHelper.dataIndexForVisualIndex(9), 8); + expect(AdHelper.dataIndexForVisualIndex(10), 9); + }); + + test('maps profile visual indices back to post indices with offset', () { + expect(AdHelper.dataIndexForVisualIndex(0, offset: AdHelper.profileAdOffset), 0); + expect(AdHelper.dataIndexForVisualIndex(3, offset: AdHelper.profileAdOffset), 3); + expect(AdHelper.dataIndexForVisualIndex(4, offset: AdHelper.profileAdOffset), 4); + expect(AdHelper.dataIndexForVisualIndex(11, offset: AdHelper.profileAdOffset), 11); + expect(AdHelper.dataIndexForVisualIndex(12, offset: AdHelper.profileAdOffset), isNull); + expect(AdHelper.dataIndexForVisualIndex(13, offset: AdHelper.profileAdOffset), 12); + }); + + test('round-trips data indices for feed and profile offsets', () { + for (final offset in [0, AdHelper.profileAdOffset]) { + final postCount = offset == 0 ? 18 : 20; + final visualCount = AdHelper.visualItemCount(postCount, offset: offset); + final seen = []; + + for (var visualIndex = 0; visualIndex < visualCount; visualIndex++) { + final dataIndex = AdHelper.dataIndexForVisualIndex(visualIndex, offset: offset); + if (dataIndex != null) { + seen.add(dataIndex); + } + } + + expect(seen, List.generate(postCount, (index) => index)); + } + }); + }); +} diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index 3500fd6..c417294 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/router/app_router.dart'; import 'package:lazurite/core/theme/app_theme.dart'; +import 'package:lazurite/features/ads/data/native_ad_repository.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; @@ -41,6 +42,19 @@ class MockConvoListBloc extends MockBloc impleme class MockNotificationRepository extends Mock implements NotificationRepository {} +class _FakeNativeAdHandle implements NativeAdHandle { + @override + Widget buildWidget() => const SizedBox.shrink(); + + @override + void dispose() {} +} + +class _FakeNativeAdRepository implements NativeAdRepository { + @override + Future loadAd({required int slotIndex}) async => _FakeNativeAdHandle(); +} + void main() { late MockAuthBloc authBloc; late MockFeedPreferencesCubit feedPreferencesCubit; @@ -52,6 +66,7 @@ void main() { late MockUnreadCountCubit unreadCountCubit; late MockConvoListBloc convoListBloc; late MockNotificationRepository notificationRepository; + late _FakeNativeAdRepository nativeAdRepository; late StreamController authController; late AuthState currentAuthState; @@ -84,6 +99,7 @@ void main() { unreadCountCubit = MockUnreadCountCubit(); convoListBloc = MockConvoListBloc(); notificationRepository = MockNotificationRepository(); + nativeAdRepository = _FakeNativeAdRepository(); authController = StreamController.broadcast(); currentAuthState = const AuthState.authenticated(tokens); @@ -166,8 +182,11 @@ void main() { BlocProvider.value(value: unreadCountCubit), BlocProvider.value(value: convoListBloc), ], - child: RepositoryProvider( - create: (_) => notificationRepository, + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider(create: (_) => notificationRepository), + RepositoryProvider.value(value: nativeAdRepository), + ], child: MaterialApp.router(routerConfig: AppRouter(authBloc: authBloc).router), ), ); @@ -302,7 +321,13 @@ void main() { providers: [BlocProvider.value(value: unreadCountCubit)], child: MultiBlocProvider( providers: [BlocProvider.value(value: convoListBloc)], - child: RepositoryProvider.value(value: notificationRepository, child: app), + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: notificationRepository), + RepositoryProvider.value(value: nativeAdRepository), + ], + child: app, + ), ), ); }, diff --git a/test/features/ads/cubit/ad_cubit_test.dart b/test/features/ads/cubit/ad_cubit_test.dart new file mode 100644 index 0000000..acdb761 --- /dev/null +++ b/test/features/ads/cubit/ad_cubit_test.dart @@ -0,0 +1,125 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/ads/cubit/ad_cubit.dart'; +import 'package:lazurite/features/ads/data/native_ad_repository.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; + +class _FakeNativeAdHandle implements NativeAdHandle { + _FakeNativeAdHandle(this.label); + + final String label; + bool disposed = false; + + @override + Widget buildWidget() => Text(label); + + @override + void dispose() { + disposed = true; + } +} + +class _FakeNativeAdRepository implements NativeAdRepository { + _FakeNativeAdRepository({Set? failSlots}) : failSlots = failSlots ?? {}; + + final Set failSlots; + final List requestedSlots = []; + final Map handles = {}; + + @override + Future loadAd({required int slotIndex}) async { + requestedSlots.add(slotIndex); + if (failSlots.contains(slotIndex)) { + return null; + } + return handles.putIfAbsent(slotIndex, () => _FakeNativeAdHandle('ad-$slotIndex')); + } +} + +void main() { + late AppDatabase database; + late SettingsCubit settingsCubit; + + setUp(() async { + database = AppDatabase(executor: NativeDatabase.memory()); + settingsCubit = SettingsCubit(database: database); + }); + + tearDown(() async { + await settingsCubit.close(); + await database.close(); + }); + + test('loads and stores ad slots for a page', () async { + final repository = _FakeNativeAdRepository(); + final cubit = AdCubit(settingsCubit: settingsCubit, nativeAdRepository: repository); + + await cubit.loadAdsForPage(0, 10); + + expect(repository.requestedSlots, [8]); + expect(cubit.state.loadedAds.keys, [8]); + + await cubit.close(); + }); + + test('does not reload slots that are already cached', () async { + final repository = _FakeNativeAdRepository(); + final cubit = AdCubit(settingsCubit: settingsCubit, nativeAdRepository: repository); + + await cubit.loadAdsForPage(0, 10); + await cubit.loadAdsForPage(1, 18); + + expect(repository.requestedSlots, [8, 17]); + expect(cubit.state.loadedAds.keys.toList()..sort(), [8, 17]); + + await cubit.close(); + }); + + test('supports profile offset slot calculation', () async { + final repository = _FakeNativeAdRepository(); + final cubit = AdCubit(settingsCubit: settingsCubit, nativeAdRepository: repository); + + await cubit.loadAdsForPage(0, 12, offset: 4); + + expect(repository.requestedSlots, [12]); + expect(cubit.state.loadedAds.keys, [12]); + + await cubit.close(); + }); + + test('disposeAd disposes the loaded handle and removes it from state', () async { + final repository = _FakeNativeAdRepository(); + final cubit = AdCubit(settingsCubit: settingsCubit, nativeAdRepository: repository); + + await cubit.loadAdsForPage(0, 10); + final handle = repository.handles[8]!; + + cubit.disposeAd(8); + + expect(handle.disposed, isTrue); + expect(cubit.state.loadedAds, isEmpty); + + await cubit.close(); + }); + + test('ads removed skips new loads and clears loaded ads', () async { + final repository = _FakeNativeAdRepository(); + final cubit = AdCubit(settingsCubit: settingsCubit, nativeAdRepository: repository); + + await cubit.loadAdsForPage(0, 10); + final handle = repository.handles[8]!; + + await settingsCubit.setAdsRemoved(true); + await Future.delayed(Duration.zero); + await cubit.loadAdsForPage(1, 18); + + expect(handle.disposed, isTrue); + expect(cubit.state.adsRemoved, isTrue); + expect(cubit.state.loadedAds, isEmpty); + expect(repository.requestedSlots, [8]); + + await cubit.close(); + }); +} diff --git a/test/features/ads/presentation/ad_post_card_test.dart b/test/features/ads/presentation/ad_post_card_test.dart new file mode 100644 index 0000000..79f500e --- /dev/null +++ b/test/features/ads/presentation/ad_post_card_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/ads/presentation/ad_post_card.dart'; + +Widget _buildSubject({required bool isLinear}) { + return MaterialApp( + home: Scaffold( + body: AdPostCard( + isLinear: isLinear, + child: const ColoredBox(color: Colors.blue, child: SizedBox(width: 100, height: 100)), + ), + ), + ); +} + +void main() { + testWidgets('renders sponsored label in grid mode', (tester) async { + await tester.pumpWidget(_buildSubject(isLinear: false)); + + expect(find.text('Sponsored'), findsOneWidget); + }); + + testWidgets('renders sponsored label with linear chrome', (tester) async { + await tester.pumpWidget(_buildSubject(isLinear: true)); + + expect(find.text('Sponsored'), findsOneWidget); + expect(find.byType(Divider), findsNWidgets(2)); + }); +} diff --git a/test/features/feed/presentation/home_feed_screen_test.dart b/test/features/feed/presentation/home_feed_screen_test.dart index ac456b8..dfb96c7 100644 --- a/test/features/feed/presentation/home_feed_screen_test.dart +++ b/test/features/feed/presentation/home_feed_screen_test.dart @@ -2,11 +2,15 @@ import 'dart:async'; import 'package:bloc_test/bloc_test.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; +import 'package:lazurite/features/ads/cubit/ad_cubit.dart'; +import 'package:lazurite/features/ads/data/native_ad_repository.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; @@ -28,11 +32,34 @@ class MockConnectivityCubit extends MockCubit implements Conn class MockAuthBloc extends MockBloc implements AuthBloc {} -SettingsState _settingsState(FeedLayout architecture) => SettingsState( +class _FakeNativeAdHandle implements NativeAdHandle { + _FakeNativeAdHandle(this.slotIndex); + + final int slotIndex; + + @override + Widget buildWidget() => ColoredBox(key: ValueKey('fake_ad_$slotIndex'), color: Colors.blue); + + @override + void dispose() {} +} + +class _FakeNativeAdRepository implements NativeAdRepository { + final List requestedSlots = []; + + @override + Future loadAd({required int slotIndex}) async { + requestedSlots.add(slotIndex); + return _FakeNativeAdHandle(slotIndex); + } +} + +SettingsState _settingsState(FeedLayout architecture, {bool adsRemoved = true}) => SettingsState( themePalette: AppThemePalette.oxocarbon, themeVariant: AppThemeVariant.dark, useSystemTheme: false, feedLayout: architecture, + adsRemoved: adsRemoved, ); const _homeFeedState = FeedPreferencesState.loaded( @@ -70,6 +97,36 @@ Widget _buildSubject({required FeedLayout architecture, double screenWidth = 400 ); } +Widget _buildAdSubject({ + required FeedLayout architecture, + required SettingsCubit settingsCubit, + required AdCubit adCubit, + double screenWidth = 400, + int itemCount = 10, +}) { + return MediaQuery( + data: MediaQueryData(size: Size(screenWidth, 800)), + child: MaterialApp( + home: Scaffold( + body: MultiBlocProvider( + providers: [ + BlocProvider.value(value: settingsCubit), + BlocProvider.value(value: adCubit), + ], + child: FeedLayoutView( + itemCount: itemCount, + scrollController: ScrollController(), + isLoadingMore: false, + onRefresh: () async {}, + gridItemBuilder: (_, i) => SizedBox(key: ValueKey('grid-$i'), child: Text('grid $i')), + linearItemBuilder: (_, i) => SizedBox(key: ValueKey('linear-$i'), child: Text('linear $i')), + ), + ), + ), + ), + ); +} + void main() { Widget buildHomeSubject({ required FeedPreferencesCubit feedPreferencesCubit, @@ -78,6 +135,8 @@ void main() { }) { final connectivityCubit = MockConnectivityCubit(); final authBloc = MockAuthBloc(); + final settingsCubit = MockSettingsCubit(); + final nativeAdRepository = _FakeNativeAdRepository(); when(() => connectivityCubit.state).thenReturn(connectivityState); whenListen(connectivityCubit, const Stream.empty(), initialState: connectivityState); when(() => authBloc.state).thenReturn( @@ -90,15 +149,21 @@ void main() { AuthTokens(accessToken: 'access', did: 'did:plc:test', handle: 'test.bsky.social'), ), ); + when(() => settingsCubit.state).thenReturn(_settingsState(FeedLayout.card)); + whenListen(settingsCubit, const Stream.empty(), initialState: _settingsState(FeedLayout.card)); return MaterialApp( - home: RepositoryProvider.value( - value: feedRepository, + home: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: feedRepository), + RepositoryProvider.value(value: nativeAdRepository), + ], child: MultiBlocProvider( providers: [ BlocProvider.value(value: authBloc), BlocProvider.value(value: feedPreferencesCubit), BlocProvider.value(value: connectivityCubit), + BlocProvider.value(value: settingsCubit), ], child: const HomeFeedScreen(), ), @@ -216,6 +281,46 @@ void main() { final listView = tester.widget(find.byType(ListView)); expect(listView.padding, const EdgeInsets.symmetric(vertical: 4)); }); + + testWidgets('injects ads at deterministic positions when ads are enabled', (tester) async { + final database = AppDatabase(executor: NativeDatabase.memory()); + addTearDown(database.close); + final settingsCubit = SettingsCubit(database: database, initialFeedLayout: FeedLayout.compact); + addTearDown(settingsCubit.close); + final repository = _FakeNativeAdRepository(); + final adCubit = AdCubit(settingsCubit: settingsCubit, nativeAdRepository: repository); + addTearDown(adCubit.close); + + await tester.pumpWidget( + _buildAdSubject(architecture: FeedLayout.compact, settingsCubit: settingsCubit, adCubit: adCubit), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('ad_slot_8')), findsOneWidget); + expect(find.byKey(const ValueKey('fake_ad_8')), findsOneWidget); + expect(find.text('linear 7'), findsOneWidget); + expect(find.text('linear 8'), findsOneWidget); + expect(repository.requestedSlots, contains(8)); + }); + + testWidgets('does not inject ads when ads have been removed', (tester) async { + final database = AppDatabase(executor: NativeDatabase.memory()); + addTearDown(database.close); + final settingsCubit = SettingsCubit(database: database, initialFeedLayout: FeedLayout.compact); + await settingsCubit.setAdsRemoved(true); + addTearDown(settingsCubit.close); + final repository = _FakeNativeAdRepository(); + final adCubit = AdCubit(settingsCubit: settingsCubit, nativeAdRepository: repository); + addTearDown(adCubit.close); + + await tester.pumpWidget( + _buildAdSubject(architecture: FeedLayout.compact, settingsCubit: settingsCubit, adCubit: adCubit), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('ad_slot_8')), findsNothing); + expect(repository.requestedSlots, isEmpty); + }); }); group('FeedLayoutView — architecture switching', () { diff --git a/test/features/profile/presentation/profile_screen_test.dart b/test/features/profile/presentation/profile_screen_test.dart index 164440e..d9d8ab2 100644 --- a/test/features/profile/presentation/profile_screen_test.dart +++ b/test/features/profile/presentation/profile_screen_test.dart @@ -11,6 +11,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; +import 'package:lazurite/features/ads/data/native_ad_repository.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; @@ -50,6 +51,28 @@ class MockPostActionCache extends Mock implements PostActionCache {} class MockListRepository extends Mock implements ListRepository {} +class _FakeNativeAdHandle implements NativeAdHandle { + _FakeNativeAdHandle(this.slotIndex); + + final int slotIndex; + + @override + Widget buildWidget() => ColoredBox(key: ValueKey('profile_fake_ad_$slotIndex'), color: Colors.blue); + + @override + void dispose() {} +} + +class _FakeNativeAdRepository implements NativeAdRepository { + final List requestedSlots = []; + + @override + Future loadAd({required int slotIndex}) async { + requestedSlots.add(slotIndex); + return _FakeNativeAdHandle(slotIndex); + } +} + void main() { late MockAuthBloc authBloc; late MockProfileBloc profileBloc; @@ -57,6 +80,7 @@ void main() { late MockSettingsCubit settingsCubit; late MockConnectivityCubit connectivityCubit; late MockProfileRepository profileRepository; + late _FakeNativeAdRepository nativeAdRepository; const tokens = AuthTokens( accessToken: 'access', @@ -99,6 +123,7 @@ void main() { settingsCubit = MockSettingsCubit(); connectivityCubit = MockConnectivityCubit(); profileRepository = MockProfileRepository(); + nativeAdRepository = _FakeNativeAdRepository(); when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); when(() => profileBloc.state).thenReturn(ProfileState.loaded(profile: profile)); @@ -129,15 +154,18 @@ void main() { }); Widget buildSubject() { - return MultiBlocProvider( - providers: [ - BlocProvider.value(value: authBloc), - BlocProvider.value(value: profileBloc), - BlocProvider.value(value: feedBloc), - BlocProvider.value(value: settingsCubit), - BlocProvider.value(value: connectivityCubit), - ], - child: const MaterialApp(home: ProfileScreen()), + return RepositoryProvider.value( + value: nativeAdRepository, + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: profileBloc), + BlocProvider.value(value: feedBloc), + BlocProvider.value(value: settingsCubit), + BlocProvider.value(value: connectivityCubit), + ], + child: const MaterialApp(home: ProfileScreen()), + ), ); } @@ -197,7 +225,10 @@ void main() { final mockProfileActionRepository = MockProfileActionRepository(); final widget = MultiRepositoryProvider( - providers: [RepositoryProvider.value(value: mockProfileActionRepository)], + providers: [ + RepositoryProvider.value(value: nativeAdRepository), + RepositoryProvider.value(value: mockProfileActionRepository), + ], child: MultiBlocProvider( providers: [ BlocProvider.value(value: authBloc), @@ -257,6 +288,7 @@ void main() { await tester.pumpWidget( MultiRepositoryProvider( providers: [ + RepositoryProvider.value(value: nativeAdRepository), RepositoryProvider.value(value: profileRepository), RepositoryProvider.value(value: mockProfileActionRepository), ], @@ -303,7 +335,10 @@ void main() { GoRoute( path: '/', builder: (context, state) => MultiRepositoryProvider( - providers: [RepositoryProvider.value(value: mockProfileActionRepository)], + providers: [ + RepositoryProvider.value(value: nativeAdRepository), + RepositoryProvider.value(value: mockProfileActionRepository), + ], child: MultiBlocProvider( providers: [ BlocProvider.value(value: authBloc), @@ -442,12 +477,18 @@ void main() { final posts = List.generate(3, (i) => makePost('$i')); - FeedState feedStateWith(List p) => - FeedState.loaded(actor: 'did:plc:me', posts: p, filter: FeedFilter.postsNoReplies, hasMore: false); + FeedState feedStateWith(List p, {FeedFilter filter = FeedFilter.postsNoReplies}) => + FeedState.loaded(actor: 'did:plc:me', posts: p, filter: filter, hasMore: false); /// Builds the profile screen with [posts] in the feed and the given SettingsCubit controlling layout mode. - Widget buildWithPosts(WidgetTester tester, MockSettingsCubit settCubit) { + Widget buildWithPosts( + WidgetTester tester, + MockSettingsCubit settCubit, { + List? customPosts, + FeedFilter filter = FeedFilter.postsNoReplies, + }) { useLargeScreen(tester); + final resolvedPosts = customPosts ?? posts; final mockPostActionRepo = MockPostActionRepository(); final mockSavedPostsCubit = MockSavedPostsCubit(); @@ -456,11 +497,12 @@ void main() { when(() => mockSavedPostsCubit.state).thenReturn(const SavedPostsState()); whenListen(mockSavedPostsCubit, const Stream.empty()); - when(() => feedBloc.state).thenReturn(feedStateWith(posts)); - whenListen(feedBloc, const Stream.empty(), initialState: feedStateWith(posts)); + when(() => feedBloc.state).thenReturn(feedStateWith(resolvedPosts, filter: filter)); + whenListen(feedBloc, const Stream.empty(), initialState: feedStateWith(resolvedPosts, filter: filter)); return MultiRepositoryProvider( providers: [ + RepositoryProvider.value(value: nativeAdRepository), RepositoryProvider.value(value: mockPostActionRepo), RepositoryProvider.value(value: mockPostActionCache), ], @@ -493,6 +535,26 @@ void main() { expect(find.byKey(const ValueKey('profile_large_card_2')), findsOneWidget); }); + testWidgets('posts tab respects the profile ad offset', (tester) async { + final cubit = MockSettingsCubit(); + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.compact)); + whenListen(cubit, const Stream.empty(), initialState: settingsStateWith(FeedLayout.compact)); + final manyPosts = List.generate(12, (i) => makePost('$i')); + + await tester.pumpWidget(buildWithPosts(tester, cubit, customPosts: manyPosts)); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.byKey(const ValueKey('ad_slot_12')), + 400, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('ad_slot_12')), findsOneWidget); + expect(find.byKey(const ValueKey('profile_fake_ad_12')), findsOneWidget); + expect(nativeAdRepository.requestedSlots, contains(12)); + }); + testWidgets('linear mode does not show the large grid card feed or metadata info card', (tester) async { final cubit = MockSettingsCubit(); when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.compact)); @@ -530,6 +592,22 @@ void main() { await streamCtrl.close(); }); + + testWidgets('non-post tabs do not render ads', (tester) async { + final cubit = MockSettingsCubit(); + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.compact)); + whenListen(cubit, const Stream.empty(), initialState: settingsStateWith(FeedLayout.compact)); + final manyPosts = List.generate(12, (i) => makePost('$i')); + + await tester.pumpWidget( + buildWithPosts(tester, cubit, customPosts: manyPosts, filter: FeedFilter.postsAndAuthorThreads), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('REPLIES')); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('ad_slot_12')), findsNothing); + }); }); group('Lists tab', () { @@ -566,7 +644,10 @@ void main() { BlocProvider.value(value: settingsCubit), ], child: MultiRepositoryProvider( - providers: [RepositoryProvider.value(value: listRepository)], + providers: [ + RepositoryProvider.value(value: nativeAdRepository), + RepositoryProvider.value(value: listRepository), + ], child: const MaterialApp(home: ProfileScreen()), ), ), @@ -598,7 +679,10 @@ void main() { await tester.pumpWidget( MultiRepositoryProvider( - providers: [RepositoryProvider.value(value: mockProfileActionRepository)], + providers: [ + RepositoryProvider.value(value: nativeAdRepository), + RepositoryProvider.value(value: mockProfileActionRepository), + ], child: MultiBlocProvider( providers: [ BlocProvider.value(value: authBloc), @@ -640,7 +724,10 @@ void main() { await tester.pumpWidget( MultiRepositoryProvider( - providers: [RepositoryProvider.value(value: mockProfileActionRepository)], + providers: [ + RepositoryProvider.value(value: nativeAdRepository), + RepositoryProvider.value(value: mockProfileActionRepository), + ], child: MultiBlocProvider( providers: [ BlocProvider.value(value: authBloc), diff --git a/test/features/settings/bloc/settings_cubit_test.dart b/test/features/settings/bloc/settings_cubit_test.dart index cc44cd1..68b6540 100644 --- a/test/features/settings/bloc/settings_cubit_test.dart +++ b/test/features/settings/bloc/settings_cubit_test.dart @@ -27,6 +27,7 @@ void main() { expect(cubit.state.feedLayout, FeedLayout.card); expect(cubit.state.simulateOffline, false); expect(cubit.state.threadAutoCollapseDepth, isNull); + expect(cubit.state.adsRemoved, false); }); test('accepts initial values via constructor', () { @@ -57,6 +58,7 @@ void main() { await database.setSetting('feed_architecture', 'linear'); await database.setSetting('simulate_offline', 'true'); await database.setSetting('thread_auto_collapse_depth', '4'); + await database.setSetting('ads_removed', 'true'); }, act: (cubit) => cubit.loadSettings(), expect: () => [ @@ -66,7 +68,8 @@ void main() { .having((s) => s.useSystemTheme, 'useSystemTheme', true) .having((s) => s.feedLayout, 'feedLayout', FeedLayout.compact) .having((s) => s.simulateOffline, 'simulateOffline', true) - .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 4), + .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', 4) + .having((s) => s.adsRemoved, 'adsRemoved', true), ], ); @@ -81,7 +84,8 @@ void main() { .having((s) => s.useSystemTheme, 'useSystemTheme', false) .having((s) => s.feedLayout, 'feedLayout', FeedLayout.card) .having((s) => s.simulateOffline, 'simulateOffline', false) - .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull), + .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull) + .having((s) => s.adsRemoved, 'adsRemoved', false), ], ); @@ -205,6 +209,17 @@ void main() { }, ); + blocTest( + 'setAdsRemoved updates state and persists to database', + build: () => SettingsCubit(database: database), + act: (cubit) => cubit.setAdsRemoved(true), + expect: () => [isA().having((s) => s.adsRemoved, 'adsRemoved', true)], + verify: (cubit) async { + final value = await database.getSetting('ads_removed'); + expect(value, 'true'); + }, + ); + blocTest( 'loadSettings round-trips feed_layout and thread auto-collapse depth', build: () => SettingsCubit(database: database), diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 0448ce7..f6d417d 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; @@ -14,6 +15,7 @@ import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:lazurite/features/settings/presentation/settings_screen.dart'; +import 'package:lazurite/features/tips/data/purchase_repository.dart'; import 'package:mocktail/mocktail.dart'; class MockAccountSwitcherCubit extends MockCubit implements AccountSwitcherCubit {} @@ -22,15 +24,19 @@ class MockAuthBloc extends MockBloc implements AuthBloc {} class MockSettingsCubit extends MockCubit implements SettingsCubit {} +class MockPurchaseRepository extends Mock implements PurchaseRepository {} + void main() { late MockAccountSwitcherCubit accountSwitcherCubit; late MockAuthBloc authBloc; late MockSettingsCubit settingsCubit; + late MockPurchaseRepository purchaseRepository; setUp(() { accountSwitcherCubit = MockAccountSwitcherCubit(); authBloc = MockAuthBloc(); settingsCubit = MockSettingsCubit(); + purchaseRepository = MockPurchaseRepository(); when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); whenListen(authBloc, const Stream.empty(), initialState: const AuthState.unauthenticated()); @@ -59,16 +65,22 @@ void main() { feedLayout: FeedLayout.card, ), ); + when(() => purchaseRepository.purchaseStream).thenAnswer((_) => const Stream>.empty()); + when(() => purchaseRepository.isAvailable()).thenAnswer((_) async => false); + when(() => purchaseRepository.fetchProducts()).thenAnswer((_) async => const []); }); Widget buildSubject() { - return MultiBlocProvider( - providers: [ - BlocProvider.value(value: authBloc), - BlocProvider.value(value: accountSwitcherCubit), - BlocProvider.value(value: settingsCubit), - ], - child: const MaterialApp(home: SettingsScreen()), + return RepositoryProvider.value( + value: purchaseRepository, + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: accountSwitcherCubit), + BlocProvider.value(value: settingsCubit), + ], + child: const MaterialApp(home: SettingsScreen()), + ), ); } @@ -77,13 +89,16 @@ void main() { routes: [ GoRoute( path: '/', - builder: (context, state) => MultiBlocProvider( - providers: [ - BlocProvider.value(value: authBloc), - BlocProvider.value(value: accountSwitcherCubit), - BlocProvider.value(value: settingsCubit), - ], - child: const SettingsScreen(), + builder: (context, state) => RepositoryProvider.value( + value: purchaseRepository, + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: accountSwitcherCubit), + BlocProvider.value(value: settingsCubit), + ], + child: const SettingsScreen(), + ), ), ), GoRoute( @@ -265,6 +280,21 @@ void main() { expect(find.text('Video Upload Limits'), findsOneWidget); expect(find.text('Check your daily video quota'), findsOneWidget); }); + + testWidgets('shows Support Lazurite row and opens the tip sheet', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible(find.text('Support Lazurite'), 300); + await tester.pumpAndSettle(); + + expect(find.text('Support Lazurite'), findsOneWidget); + + await tester.tap(find.text('Support Lazurite')); + await tester.pumpAndSettle(); + + expect(find.text('Store unavailable'), findsOneWidget); + }); } String _buildJwt({required String aud, required String sub, required String clientId, required String iss}) { diff --git a/test/features/tips/cubit/tip_cubit_test.dart b/test/features/tips/cubit/tip_cubit_test.dart new file mode 100644 index 0000000..6550afe --- /dev/null +++ b/test/features/tips/cubit/tip_cubit_test.dart @@ -0,0 +1,159 @@ +import 'dart:async'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/tips/cubit/tip_cubit.dart'; +import 'package:lazurite/features/tips/cubit/tip_state.dart'; +import 'package:lazurite/features/tips/data/purchase_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockPurchaseRepository extends Mock implements PurchaseRepository {} + +PurchaseDetails _purchase({ + required PurchaseStatus status, + String productId = PurchaseRepository.coffeeProductId, + bool pendingCompletePurchase = false, + String? errorMessage, +}) { + final purchase = PurchaseDetails( + productID: productId, + verificationData: PurchaseVerificationData( + localVerificationData: 'local', + serverVerificationData: 'server', + source: 'test', + ), + transactionDate: '123', + status: status, + ); + purchase.pendingCompletePurchase = pendingCompletePurchase; + if (errorMessage != null) { + purchase.error = IAPError(source: 'test', code: 'purchase-error', message: errorMessage); + } + return purchase; +} + +void main() { + late AppDatabase database; + late SettingsCubit settingsCubit; + late MockPurchaseRepository repository; + late StreamController> purchaseController; + late ProductDetails coffee; + + setUp(() async { + database = AppDatabase(executor: NativeDatabase.memory()); + settingsCubit = SettingsCubit(database: database); + repository = MockPurchaseRepository(); + purchaseController = StreamController>.broadcast(); + coffee = ProductDetails( + id: PurchaseRepository.coffeeProductId, + title: 'Coffee', + description: 'Small tip', + price: r'$1.99', + rawPrice: 1.99, + currencyCode: 'USD', + currencySymbol: r'$', + ); + registerFallbackValue( + PurchaseDetails( + productID: coffee.id, + verificationData: PurchaseVerificationData( + localVerificationData: 'local', + serverVerificationData: 'server', + source: 'test', + ), + transactionDate: '123', + status: PurchaseStatus.purchased, + ), + ); + + when(() => repository.purchaseStream).thenAnswer((_) => purchaseController.stream); + when(() => repository.isAvailable()).thenAnswer((_) async => true); + when(() => repository.fetchProducts()).thenAnswer((_) async => [coffee]); + when(() => repository.buyTip(coffee)).thenAnswer((_) async {}); + when(() => repository.completePurchase(any())).thenAnswer((_) async {}); + }); + + tearDown(() async { + await purchaseController.close(); + await settingsCubit.close(); + await database.close(); + }); + + test('loadProducts exposes available products', () async { + final cubit = TipCubit(purchaseRepository: repository, settingsCubit: settingsCubit); + + await cubit.loadProducts(); + + expect(cubit.state.storeStatus, TipStoreStatus.available); + expect(cubit.state.products, [coffee]); + + await cubit.close(); + }); + + test('loadProducts reports unavailable store', () async { + when(() => repository.isAvailable()).thenAnswer((_) async => false); + final cubit = TipCubit(purchaseRepository: repository, settingsCubit: settingsCubit); + + await cubit.loadProducts(); + + expect(cubit.state.storeStatus, TipStoreStatus.unavailable); + + await cubit.close(); + }); + + test('purchaseTip reports request errors', () async { + when(() => repository.buyTip(coffee)).thenThrow(Exception('boom')); + final cubit = TipCubit(purchaseRepository: repository, settingsCubit: settingsCubit); + + await cubit.purchaseTip(coffee); + + expect(cubit.state.purchaseStatus, TipPurchaseStatus.error); + expect(cubit.state.errorMessage, contains('boom')); + + await cubit.close(); + }); + + test('purchase stream success sets ads removed and completes the purchase', () async { + final cubit = TipCubit(purchaseRepository: repository, settingsCubit: settingsCubit); + + purchaseController.add([_purchase(status: PurchaseStatus.purchased, pendingCompletePurchase: true)]); + await Future.delayed(Duration.zero); + + expect(cubit.state.purchaseStatus, TipPurchaseStatus.success); + expect(cubit.state.adsRemoved, isTrue); + expect(settingsCubit.state.adsRemoved, isTrue); + expect(await database.getSetting('ads_removed'), 'true'); + verify(() => repository.completePurchase(any())).called(1); + + await cubit.close(); + }); + + test('purchase stream error exposes the store error', () async { + final cubit = TipCubit(purchaseRepository: repository, settingsCubit: settingsCubit); + + purchaseController.add([ + _purchase(status: PurchaseStatus.error, pendingCompletePurchase: true, errorMessage: 'Purchase failed'), + ]); + await Future.delayed(Duration.zero); + + expect(cubit.state.purchaseStatus, TipPurchaseStatus.error); + expect(cubit.state.errorMessage, 'Purchase failed'); + verify(() => repository.completePurchase(any())).called(1); + + await cubit.close(); + }); + + test('pending purchase updates keep the sheet in loading state', () async { + final cubit = TipCubit(purchaseRepository: repository, settingsCubit: settingsCubit); + + purchaseController.add([_purchase(status: PurchaseStatus.pending)]); + await Future.delayed(Duration.zero); + + expect(cubit.state.purchaseStatus, TipPurchaseStatus.pending); + + await cubit.close(); + }); +} diff --git a/test/features/tips/data/purchase_repository_test.dart b/test/features/tips/data/purchase_repository_test.dart new file mode 100644 index 0000000..b328e54 --- /dev/null +++ b/test/features/tips/data/purchase_repository_test.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:lazurite/features/tips/data/purchase_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockInAppPurchase extends Mock implements InAppPurchase {} + +void main() { + late MockInAppPurchase iap; + late StreamController> purchaseController; + late ProductDetails product; + late PurchaseDetails purchase; + + setUp(() { + iap = MockInAppPurchase(); + purchaseController = StreamController>.broadcast(); + product = ProductDetails( + id: PurchaseRepository.coffeeProductId, + title: 'Coffee', + description: 'Small tip', + price: r'$1.99', + rawPrice: 1.99, + currencyCode: 'USD', + currencySymbol: r'$', + ); + purchase = PurchaseDetails( + productID: product.id, + verificationData: PurchaseVerificationData( + localVerificationData: 'local', + serverVerificationData: 'server', + source: 'test', + ), + transactionDate: '123', + status: PurchaseStatus.purchased, + ); + registerFallbackValue(PurchaseParam(productDetails: product)); + registerFallbackValue(purchase); + + when(() => iap.purchaseStream).thenAnswer((_) => purchaseController.stream); + when(() => iap.isAvailable()).thenAnswer((_) async => true); + when( + () => iap.queryProductDetails(PurchaseRepository.productIds), + ).thenAnswer((_) async => ProductDetailsResponse(productDetails: [product], notFoundIDs: const [])); + when(() => iap.buyConsumable(purchaseParam: any(named: 'purchaseParam'))).thenAnswer((_) async => true); + when(() => iap.completePurchase(any())).thenAnswer((_) async {}); + }); + + tearDown(() async { + await purchaseController.close(); + }); + + test('delegates store availability checks', () async { + final repository = InAppPurchaseRepository(iap: iap); + + expect(await repository.isAvailable(), isTrue); + verify(() => iap.isAvailable()).called(1); + }); + + test('queries the configured tip products', () async { + final repository = InAppPurchaseRepository(iap: iap); + + final result = await repository.fetchProducts(); + + expect(result, [product]); + verify(() => iap.queryProductDetails(PurchaseRepository.productIds)).called(1); + }); + + test('starts a consumable purchase', () async { + final repository = InAppPurchaseRepository(iap: iap); + + await repository.buyTip(product); + + verify(() => iap.buyConsumable(purchaseParam: any(named: 'purchaseParam'))).called(1); + }); + + test('exposes the purchase stream and completes purchases', () async { + final repository = InAppPurchaseRepository(iap: iap); + final emitted = >[]; + final sub = repository.purchaseStream.listen(emitted.add); + purchaseController.add([purchase]); + await Future.delayed(Duration.zero); + + await repository.completePurchase(purchase); + + expect(emitted, [ + [purchase], + ]); + verify(() => iap.completePurchase(purchase)).called(1); + await sub.cancel(); + }); +} diff --git a/test/features/tips/presentation/tip_sheet_test.dart b/test/features/tips/presentation/tip_sheet_test.dart new file mode 100644 index 0000000..cbea736 --- /dev/null +++ b/test/features/tips/presentation/tip_sheet_test.dart @@ -0,0 +1,108 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:lazurite/features/tips/cubit/tip_cubit.dart'; +import 'package:lazurite/features/tips/cubit/tip_state.dart'; +import 'package:lazurite/features/tips/presentation/tip_sheet.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockTipCubit extends MockCubit implements TipCubit {} + +void main() { + late MockTipCubit cubit; + late ProductDetails coffee; + late ProductDetails latte; + + setUp(() { + cubit = MockTipCubit(); + coffee = ProductDetails( + id: 'tip_coffee', + title: 'Coffee', + description: 'Small tip', + price: r'$1.99', + rawPrice: 1.99, + currencyCode: 'USD', + currencySymbol: r'$', + ); + latte = ProductDetails( + id: 'tip_latte', + title: 'Latte', + description: 'Large tip', + price: r'$4.99', + rawPrice: 4.99, + currencyCode: 'USD', + currencySymbol: r'$', + ); + registerFallbackValue(coffee); + + when(() => cubit.loadProducts()).thenAnswer((_) async {}); + when(() => cubit.purchaseTip(any())).thenAnswer((_) async {}); + }); + + Widget buildSubject(TipState state) { + when(() => cubit.state).thenReturn(state); + whenListen(cubit, const Stream.empty(), initialState: state); + + return MaterialApp( + home: Scaffold( + body: BlocProvider.value(value: cubit, child: const TipSheet()), + ), + ); + } + + testWidgets('renders loading skeletons while products load', (tester) async { + await tester.pumpWidget(buildSubject(const TipState(storeStatus: TipStoreStatus.loading))); + + expect(find.byKey(const Key('tip_skeleton_0')), findsOneWidget); + expect(find.byKey(const Key('tip_skeleton_1')), findsOneWidget); + }); + + testWidgets('renders products with localized prices and ads note', (tester) async { + await tester.pumpWidget( + buildSubject(TipState(storeStatus: TipStoreStatus.available, products: [latte, coffee], adsRemoved: false)), + ); + + expect(find.text('Coffee'), findsOneWidget); + expect(find.text('Latte'), findsOneWidget); + expect(find.text(r'$1.99'), findsOneWidget); + expect(find.text(r'$4.99'), findsOneWidget); + expect(find.text('Your first tip removes ads forever.'), findsOneWidget); + }); + + testWidgets('renders thank-you banner when ads are already removed', (tester) async { + await tester.pumpWidget( + buildSubject(TipState(storeStatus: TipStoreStatus.available, products: [coffee, latte], adsRemoved: true)), + ); + + expect(find.text('Ads removed — thanks for your support!'), findsOneWidget); + expect(find.text('Your first tip removes ads forever.'), findsNothing); + }); + + testWidgets('renders store unavailable state and retries', (tester) async { + await tester.pumpWidget(buildSubject(const TipState(storeStatus: TipStoreStatus.unavailable))); + + expect(find.text('Store unavailable'), findsOneWidget); + + await tester.tap(find.text('Retry')); + await tester.pump(); + + verify(() => cubit.loadProducts()).called(1); + }); + + testWidgets('shows pending spinner and disables other purchases', (tester) async { + await tester.pumpWidget( + buildSubject( + TipState( + storeStatus: TipStoreStatus.available, + products: [coffee, latte], + purchaseStatus: TipPurchaseStatus.pending, + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsNWidgets(2)); + expect(find.byType(FilledButton), findsNothing); + }); +}