diff --git a/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart b/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart index d2f58b37..5ddf04b9 100644 --- a/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart +++ b/lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart @@ -2,6 +2,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; import 'package:go_router/go_router.dart'; @@ -96,6 +97,9 @@ class FollowedCountriesListPage extends StatelessWidget { }, ), onTap: () { + context.read().onPotentialAdTrigger( + context: context, + ); context.pushNamed( Routes.entityDetailsName, pathParameters: { diff --git a/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart b/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart index bc237365..2329e34b 100644 --- a/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart +++ b/lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart @@ -2,6 +2,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; import 'package:go_router/go_router.dart'; @@ -93,6 +94,9 @@ class FollowedSourcesListPage extends StatelessWidget { }, ), onTap: () { + context.read().onPotentialAdTrigger( + context: context, + ); context.pushNamed( Routes.entityDetailsName, pathParameters: { diff --git a/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart b/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart index fed20daa..29e64db6 100644 --- a/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart +++ b/lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart @@ -2,6 +2,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; import 'package:go_router/go_router.dart'; @@ -101,6 +102,9 @@ class FollowedTopicsListPage extends StatelessWidget { }, ), onTap: () { + context.read().onPotentialAdTrigger( + context: context, + ); context.pushNamed( Routes.entityDetailsName, pathParameters: { diff --git a/lib/account/view/saved_headlines_page.dart b/lib/account/view/saved_headlines_page.dart index 56b70652..9f41c17f 100644 --- a/lib/account/view/saved_headlines_page.dart +++ b/lib/account/view/saved_headlines_page.dart @@ -2,6 +2,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; // HeadlineItemWidget import removed import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; @@ -105,31 +106,46 @@ class SavedHeadlinesPage extends StatelessWidget { case HeadlineImageStyle.hidden: tile = HeadlineTileTextOnly( headline: headline, - onHeadlineTap: () => context.goNamed( - Routes.accountArticleDetailsName, - pathParameters: {'id': headline.id}, - extra: headline, - ), + onHeadlineTap: () { + context + .read() + .onPotentialAdTrigger(context: context); + context.goNamed( + Routes.accountArticleDetailsName, + pathParameters: {'id': headline.id}, + extra: headline, + ); + }, trailing: trailingButton, ); case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: headline, - onHeadlineTap: () => context.goNamed( - Routes.accountArticleDetailsName, - pathParameters: {'id': headline.id}, - extra: headline, - ), + onHeadlineTap: () { + context + .read() + .onPotentialAdTrigger(context: context); + context.goNamed( + Routes.accountArticleDetailsName, + pathParameters: {'id': headline.id}, + extra: headline, + ); + }, trailing: trailingButton, ); case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: headline, - onHeadlineTap: () => context.goNamed( - Routes.accountArticleDetailsName, - pathParameters: {'id': headline.id}, - extra: headline, - ), + onHeadlineTap: () { + context + .read() + .onPotentialAdTrigger(context: context); + context.goNamed( + Routes.accountArticleDetailsName, + pathParameters: {'id': headline.id}, + extra: headline, + ); + }, trailing: trailingButton, ); } diff --git a/lib/ads/ad_navigator_observer.dart b/lib/ads/ad_navigator_observer.dart deleted file mode 100644 index 7e3d130f..00000000 --- a/lib/ads/ad_navigator_observer.dart +++ /dev/null @@ -1,323 +0,0 @@ -import 'dart:async'; - -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/ads/ad_service.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/ads/widgets/widgets.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/app/config/app_environment.dart'; -import 'package:google_mobile_ads/google_mobile_ads.dart' as admob; -import 'package:logging/logging.dart'; - -/// A function that provides the current [AppState]. -/// -/// This is used for dependency injection to decouple the [AdNavigatorObserver] -/// from a direct dependency on the [AppBloc] instance, making it more -/// testable and reusable. -typedef AppStateProvider = AppState Function(); - -/// {@template ad_navigator_observer} -/// A [NavigatorObserver] that listens to route changes and triggers -/// interstitial ad display based on [RemoteConfig] settings. -/// -/// This observer is responsible for: -/// 1. Tracking page transitions to determine when an interstitial ad should be shown. -/// 2. Requesting an interstitial ad from the [AdService] when the criteria are met. -/// 3. Showing the interstitial ad to the user. -/// -/// It retrieves the current [AppState] via the [appStateProvider] to get the -/// latest [RemoteConfig] and user's ad frequency settings. -/// {@endtemplate} -class AdNavigatorObserver extends NavigatorObserver { - /// {@macro ad_navigator_observer} - AdNavigatorObserver({ - required this.appStateProvider, - required this.adService, - required AdThemeStyle adThemeStyle, - Logger? logger, - }) : _logger = logger ?? Logger('AdNavigatorObserver'), - _adThemeStyle = adThemeStyle; - - /// A function that provides the current [AppState]. - final AppStateProvider appStateProvider; - - /// The service responsible for fetching and loading ads. - final AdService adService; - - final Logger _logger; - final AdThemeStyle _adThemeStyle; - - /// Tracks the number of page transitions since the last interstitial ad. - int _pageTransitionCount = 0; - - /// Stores the name of the previous route. - String? _previousRouteName; - - @override - void didPush(Route route, Route? previousRoute) { - super.didPush(route, previousRoute); - final currentRouteName = route.settings.name; - _logger.info( - 'AdNavigatorObserver: Route pushed: $currentRouteName (Previous: $_previousRouteName)', - ); - if (route is PageRoute && currentRouteName != null) { - _handlePageTransition(currentRouteName); - } - _previousRouteName = currentRouteName; - } - - @override - void didPop(Route route, Route? previousRoute) { - super.didPop(route, previousRoute); - final currentRouteName = previousRoute - ?.settings - .name; // After pop, previousRoute is the new current - _logger.info( - 'AdNavigatorObserver: Route popped: ${route.settings.name} (New Current: $currentRouteName)', - ); - if (route is PageRoute && currentRouteName != null) { - _handlePageTransition(currentRouteName); - } - _previousRouteName = currentRouteName; - } - - /// Determines if a route transition is eligible for an interstitial ad. - /// - /// An ad is considered eligible if the transition is from a content list - /// (e.g., feed, search) to a detail page (e.g., article, entity details). - bool _isEligibleForInterstitialAd(String currentRouteName) { - // Define content list routes - const contentListRoutes = { - 'feed', - 'search', - 'followedTopicsList', - 'followedSourcesList', - 'followedCountriesList', - 'accountSavedHeadlines', - }; - - // Define detail page routes - const detailPageRoutes = { - 'articleDetails', - 'searchArticleDetails', - 'accountArticleDetails', - 'globalArticleDetails', - 'entityDetails', - }; - - final previous = _previousRouteName; - final current = currentRouteName; - - final isFromContentList = - previous != null && contentListRoutes.contains(previous); - final isToDetailPage = detailPageRoutes.contains(current); - - _logger.info( - 'AdNavigatorObserver: Eligibility check: Previous: $previous (Is Content List: $isFromContentList), ' - 'Current: $current (Is Detail Page: $isToDetailPage)', - ); - - return isFromContentList && isToDetailPage; - } - - /// Handles a page transition event, checks ad frequency, and shows an ad if needed. - void _handlePageTransition(String currentRouteName) { - final appState = appStateProvider(); - final remoteConfig = appState.remoteConfig; - final user = appState.user; - - _logger.info( - 'AdNavigatorObserver: _handlePageTransition called for route: $currentRouteName', - ); - - // Only proceed if remote config is available, ads are globally enabled, - // and interstitial ads are enabled in the config. - if (remoteConfig == null) { - _logger.warning( - 'AdNavigatorObserver: RemoteConfig is null. Cannot check ad enablement.', - ); - return; - } - if (!remoteConfig.adConfig.enabled) { - _logger.info( - 'AdNavigatorObserver: Ads are globally disabled in RemoteConfig.', - ); - return; - } - if (!remoteConfig.adConfig.interstitialAdConfiguration.enabled) { - _logger.info( - 'AdNavigatorObserver: Interstitial ads are disabled in RemoteConfig.', - ); - return; - } - - // Only increment count if the transition is eligible for an interstitial ad. - if (_isEligibleForInterstitialAd(currentRouteName)) { - _pageTransitionCount++; - _logger.info( - 'AdNavigatorObserver: Eligible page transition. Current count: $_pageTransitionCount', - ); - } else { - _logger.info( - 'AdNavigatorObserver: Ineligible page transition. Count remains: $_pageTransitionCount', - ); - return; // Do not proceed if not an eligible transition - } - - final interstitialConfig = - remoteConfig.adConfig.interstitialAdConfiguration; - final frequencyConfig = interstitialConfig - .feedInterstitialAdFrequencyConfig; // Using existing name - - // Determine the required transitions based on user role. - final int requiredTransitions; - switch (user?.appRole) { - case AppUserRole.guestUser: - requiredTransitions = - frequencyConfig.guestTransitionsBeforeShowingInterstitialAds; - case AppUserRole.standardUser: - requiredTransitions = - frequencyConfig.standardUserTransitionsBeforeShowingInterstitialAds; - case AppUserRole.premiumUser: - requiredTransitions = - frequencyConfig.premiumUserTransitionsBeforeShowingInterstitialAds; - case null: - // If user is null, default to guest user settings. - requiredTransitions = - frequencyConfig.guestTransitionsBeforeShowingInterstitialAds; - } - - _logger.info( - 'AdNavigatorObserver: Required transitions for user role ${user?.appRole}: $requiredTransitions. ' - 'Current eligible transitions: $_pageTransitionCount', - ); - - // Check if it's time to show an interstitial ad. - if (requiredTransitions > 0 && - _pageTransitionCount >= requiredTransitions) { - _logger.info('AdNavigatorObserver: Interstitial ad due. Requesting ad.'); - unawaited(_showInterstitialAd()); // Use unawaited to not block navigation - // Reset count only after an ad is due (whether it shows or fails) - _pageTransitionCount = 0; - } else { - _logger.info( - 'AdNavigatorObserver: Interstitial ad not yet due. ' - 'Required: $requiredTransitions, Current: $_pageTransitionCount', - ); - } - } - - /// Requests and shows an interstitial ad if conditions are met. - Future _showInterstitialAd() async { - _logger.info('AdNavigatorObserver: Attempting to show interstitial ad.'); - final appState = appStateProvider(); - final appEnvironment = appState.environment; - final remoteConfig = appState.remoteConfig; - - // In demo environment, display a placeholder interstitial ad directly. - if (appEnvironment == AppEnvironment.demo) { - _logger.info( - 'AdNavigatorObserver: Demo environment: Showing placeholder interstitial ad.', - ); - if (navigator?.context == null) { - _logger.severe( - 'AdNavigatorObserver: Navigator context is null. Cannot show demo interstitial ad.', - ); - return; - } - await showDialog( - context: navigator!.context, - builder: (context) => const DemoInterstitialAdDialog(), - ); - _logger.info('AdNavigatorObserver: Placeholder interstitial ad shown.'); - return; - } - - // For other environments (development, production), proceed with real ad loading. - // This is a secondary check. The primary check is in _handlePageTransition. - if (remoteConfig == null || !remoteConfig.adConfig.enabled) { - _logger.warning( - 'AdNavigatorObserver: Interstitial ads disabled or remote config not available. ' - 'This should have been caught earlier in _handlePageTransition.', - ); - return; - } - - final adConfig = remoteConfig.adConfig; - final interstitialConfig = adConfig.interstitialAdConfiguration; - - if (!interstitialConfig.enabled) { - _logger.warning( - 'AdNavigatorObserver: Interstitial ads are specifically disabled in config. ' - 'This should have been caught earlier in _handlePageTransition.', - ); - return; - } - - _logger.info( - 'AdNavigatorObserver: Requesting interstitial ad from AdService...', - ); - final interstitialAd = await adService.getInterstitialAd( - adConfig: adConfig, - adThemeStyle: _adThemeStyle, - ); - - if (interstitialAd != null) { - _logger.info('AdNavigatorObserver: Interstitial ad loaded. Showing...'); - if (navigator?.context == null) { - _logger.severe( - 'AdNavigatorObserver: Navigator context is null. Cannot show interstitial ad.', - ); - return; - } - // Show the AdMob interstitial ad. - if (interstitialAd.provider == AdPlatformType.admob && - interstitialAd.adObject is admob.InterstitialAd) { - _logger.info('AdNavigatorObserver: Showing AdMob interstitial ad.'); - final admobInterstitialAd = - interstitialAd.adObject as admob.InterstitialAd - ..fullScreenContentCallback = admob.FullScreenContentCallback( - onAdDismissedFullScreenContent: (ad) { - _logger.info( - 'AdNavigatorObserver: AdMob Interstitial Ad dismissed.', - ); - ad.dispose(); - }, - onAdFailedToShowFullScreenContent: (ad, error) { - _logger.severe( - 'AdNavigatorObserver: AdMob Interstitial Ad failed to show: $error', - ); - ad.dispose(); - }, - onAdShowedFullScreenContent: (ad) { - _logger.info( - 'AdNavigatorObserver: AdMob Interstitial Ad showed.', - ); - }, - ); - await admobInterstitialAd.show(); - } else if (interstitialAd.provider == AdPlatformType.local && - interstitialAd.adObject is LocalInterstitialAd) { - _logger.info('AdNavigatorObserver: Showing local interstitial ad.'); - await showDialog( - context: navigator!.context, - builder: (context) => LocalInterstitialAdDialog( - localInterstitialAd: interstitialAd.adObject as LocalInterstitialAd, - ), - ); - _logger.info('AdNavigatorObserver: Local interstitial ad shown.'); - } else { - _logger.warning( - 'AdNavigatorObserver: Loaded interstitial ad has unknown provider ' - 'or adObject type: ${interstitialAd.provider}, ${interstitialAd.adObject.runtimeType}', - ); - } - } else { - _logger.warning( - 'AdNavigatorObserver: No interstitial ad loaded by AdService, even though one was due. ' - 'Check AdService implementation and ad unit availability.', - ); - } - } -} diff --git a/lib/ads/interstitial_ad_manager.dart b/lib/ads/interstitial_ad_manager.dart new file mode 100644 index 00000000..5229d40d --- /dev/null +++ b/lib/ads/interstitial_ad_manager.dart @@ -0,0 +1,268 @@ +import 'dart:async'; + +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/ad_service.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/interstitial_ad.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/widgets/widgets.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart' as admob; +import 'package:logging/logging.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template interstitial_ad_manager} +/// A service that manages the lifecycle of interstitial ads. +/// +/// This manager listens to the [AppBloc] to stay aware of the current +/// [RemoteConfig] and user state. It proactively pre-loads an interstitial +/// ad when conditions are met and provides a mechanism to show it upon +/// an explicit trigger from the UI. +/// {@endtemplate} +class InterstitialAdManager { + /// {@macro interstitial_ad_manager} + InterstitialAdManager({ + required AppBloc appBloc, + required AdService adService, + Logger? logger, + }) : _appBloc = appBloc, + _adService = adService, + _logger = logger ?? Logger('InterstitialAdManager') { + // Listen to the AppBloc stream to react to state changes. + _appBlocSubscription = _appBloc.stream.listen(_onAppStateChanged); + // Initialize with the current state. + _onAppStateChanged(_appBloc.state); + } + + final AppBloc _appBloc; + final AdService _adService; + final Logger _logger; + + late final StreamSubscription _appBlocSubscription; + + /// The currently pre-loaded interstitial ad. + InterstitialAd? _preloadedAd; + + /// Tracks the number of eligible page transitions since the last ad was shown. + int _transitionCount = 0; + + /// The current remote configuration for ads. + AdConfig? _adConfig; + + /// The current user role. + AppUserRole? _userRole; + + /// Disposes the manager and cancels stream subscriptions. + void dispose() { + _appBlocSubscription.cancel(); + _disposePreloadedAd(); + } + + /// Handles changes in the [AppState]. + void _onAppStateChanged(AppState state) { + final newAdConfig = state.remoteConfig?.adConfig; + final newUserRole = state.user?.appRole; + + // If the ad config or user role has changed, update internal state + // and potentially pre-load a new ad. + if (newAdConfig != _adConfig || newUserRole != _userRole) { + _logger.info('Ad config or user role changed. Updating internal state.'); + _adConfig = newAdConfig; + _userRole = newUserRole; + // A config change might mean we need to load an ad now. + _maybePreloadAd(state); + } + } + + /// Pre-loads an interstitial ad if one is not already loaded and conditions are met. + /// + /// This method now takes the current [AppState] to derive theme information + /// without needing a [BuildContext]. + Future _maybePreloadAd(AppState appState) async { + if (_preloadedAd != null) { + _logger.info('An interstitial ad is already pre-loaded. Skipping.'); + return; + } + + final adConfig = _adConfig; + if (adConfig == null || + !adConfig.enabled || + !adConfig.interstitialAdConfiguration.enabled) { + _logger.info('Interstitial ads are disabled. Skipping pre-load.'); + return; + } + + _logger.info('Attempting to pre-load an interstitial ad...'); + try { + // Determine the brightness for theme creation. + // If themeMode is system, use platform brightness. + final brightness = appState.themeMode == ThemeMode.system + ? SchedulerBinding.instance.window.platformBrightness + : (appState.themeMode == ThemeMode.dark + ? Brightness.dark + : Brightness.light); + + // Create a ThemeData instance from the AppState's settings. + // This allows us to derive AdThemeStyle without a BuildContext. + final themeData = brightness == Brightness.light + ? lightTheme( + scheme: appState.flexScheme, + appTextScaleFactor: + appState.settings.displaySettings.textScaleFactor, + appFontWeight: appState.settings.displaySettings.fontWeight, + fontFamily: appState.settings.displaySettings.fontFamily, + ) + : darkTheme( + scheme: appState.flexScheme, + appTextScaleFactor: + appState.settings.displaySettings.textScaleFactor, + appFontWeight: appState.settings.displaySettings.fontWeight, + fontFamily: appState.settings.displaySettings.fontFamily, + ); + + final adThemeStyle = AdThemeStyle.fromTheme(themeData); + + final ad = await _adService.getInterstitialAd( + adConfig: adConfig, + adThemeStyle: adThemeStyle, + ); + + if (ad != null) { + _preloadedAd = ad; + _logger.info('Interstitial ad pre-loaded successfully.'); + } else { + _logger.warning('Failed to pre-load interstitial ad.'); + } + } catch (e, s) { + _logger.severe('Error pre-loading interstitial ad: $e', e, s); + } + } + + /// Disposes the currently pre-loaded ad to release its resources. + void _disposePreloadedAd() { + if (_preloadedAd?.provider == AdPlatformType.admob && + _preloadedAd?.adObject is admob.InterstitialAd) { + _logger.info('Disposing pre-loaded AdMob interstitial ad.'); + (_preloadedAd!.adObject as admob.InterstitialAd).dispose(); + } + _preloadedAd = null; + } + + /// Called by the UI before an ad-eligible navigation occurs. + /// + /// This method increments the transition counter and shows a pre-loaded ad + /// if the frequency criteria are met. + Future onPotentialAdTrigger({required BuildContext context}) async { + _transitionCount++; + _logger.info('Potential ad trigger. Transition count: $_transitionCount'); + + final adConfig = _adConfig; + if (adConfig == null) { + _logger.warning('No ad config available. Cannot determine ad frequency.'); + return; + } + + final frequencyConfig = + adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig; + final requiredTransitions = _getRequiredTransitions(frequencyConfig); + + if (requiredTransitions > 0 && _transitionCount >= requiredTransitions) { + _logger.info('Transition count meets threshold. Attempting to show ad.'); + await _showAd(context); + _transitionCount = + 0; // Reset counter after showing (or attempting to show) + } else { + _logger.info( + 'Transition count ($_transitionCount) has not met threshold ($requiredTransitions).', + ); + } + } + + /// Shows the pre-loaded interstitial ad. + Future _showAd(BuildContext context) async { + if (_preloadedAd == null) { + _logger.warning( + 'Show ad called, but no ad is pre-loaded. Pre-loading now.', + ); + // Attempt a last-minute load if no ad is ready. + await _maybePreloadAd(_appBloc.state); + if (_preloadedAd == null) { + _logger.severe('Last-minute ad load failed. Cannot show ad.'); + return; + } + } + + final adToShow = _preloadedAd!; + _preloadedAd = null; // Clear the pre-loaded ad before showing + + try { + switch (adToShow.provider) { + case AdPlatformType.admob: + await _showAdMobAd(adToShow); + case AdPlatformType.local: + // ignore: use_build_context_synchronously + await _showLocalAd(context, adToShow); + case AdPlatformType.demo: + // ignore: use_build_context_synchronously + await _showDemoAd(context); + } + } catch (e, s) { + _logger.severe('Error showing interstitial ad: $e', e, s); + } finally { + // After the ad is shown or fails to show, dispose of it and + // start pre-loading the next one for the next opportunity. + _disposePreloadedAd(); // Ensure the ad object is disposed + unawaited(_maybePreloadAd(_appBloc.state)); + } + } + + Future _showAdMobAd(InterstitialAd ad) async { + if (ad.adObject is! admob.InterstitialAd) return; + final admobAd = ad.adObject as admob.InterstitialAd + ..fullScreenContentCallback = admob.FullScreenContentCallback( + onAdShowedFullScreenContent: (ad) => + _logger.info('AdMob ad showed full screen.'), + onAdDismissedFullScreenContent: (ad) { + _logger.info('AdMob ad dismissed.'); + ad.dispose(); + }, + onAdFailedToShowFullScreenContent: (ad, error) { + _logger.severe('AdMob ad failed to show: $error'); + ad.dispose(); + }, + ); + await admobAd.show(); + } + + Future _showLocalAd(BuildContext context, InterstitialAd ad) async { + if (ad.adObject is! LocalInterstitialAd) return; + await showDialog( + context: context, + builder: (_) => LocalInterstitialAdDialog( + localInterstitialAd: ad.adObject as LocalInterstitialAd, + ), + ); + } + + Future _showDemoAd(BuildContext context) async { + await showDialog( + context: context, + builder: (_) => const DemoInterstitialAdDialog(), + ); + } + + /// Determines the required number of transitions based on the user's role. + int _getRequiredTransitions(InterstitialAdFrequencyConfig config) { + switch (_userRole) { + case AppUserRole.guestUser: + return config.guestTransitionsBeforeShowingInterstitialAds; + case AppUserRole.standardUser: + return config.standardUserTransitionsBeforeShowingInterstitialAds; + case AppUserRole.premiumUser: + return config.premiumUserTransitionsBeforeShowingInterstitialAds; + case null: + return config.guestTransitionsBeforeShowingInterstitialAds; + } + } +} diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index c2429374..8bb57ad8 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -25,6 +25,7 @@ class AppBloc extends Bloc { required DataRepository userRepository, required local_config.AppEnvironment environment, required AdService adService, + required GlobalKey navigatorKey, this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, @@ -34,6 +35,7 @@ class AppBloc extends Bloc { _userRepository = userRepository, _environment = environment, _adService = adService, + _navigatorKey = navigatorKey, _logger = Logger('AppBloc'), super( AppState( @@ -92,12 +94,20 @@ class AppBloc extends Bloc { final DataRepository _userRepository; final local_config.AppEnvironment _environment; final AdService _adService; + final GlobalKey _navigatorKey; final Logger _logger; final DemoDataMigrationService? demoDataMigrationService; final DemoDataInitializerService? demoDataInitializerService; final User? initialUser; late final StreamSubscription _userSubscription; + /// Provides access to the [NavigatorState] for obtaining a [BuildContext]. + /// + /// This is useful for services that need a [BuildContext] but are not + /// directly part of the widget tree (e.g., for showing dialogs or + /// deriving theme data). + GlobalKey get navigatorKey => _navigatorKey; + /// Handles user changes and loads initial settings once user is available. Future _onAppUserChanged( AppUserChanged event, @@ -230,15 +240,16 @@ class AppBloc extends Bloc { // Map language code to Locale final newLocale = Locale(userAppSettings.language.code); - _logger.info( - '_onAppSettingsRefreshed: userAppSettings.fontFamily: ${userAppSettings.displaySettings.fontFamily}', - ); - _logger.info( - '_onAppSettingsRefreshed: userAppSettings.fontWeight: ${userAppSettings.displaySettings.fontWeight}', - ); - _logger.info( - '_onAppSettingsRefreshed: newFontFamily mapped to: $newFontFamily', - ); + _logger + ..info( + '_onAppSettingsRefreshed: userAppSettings.fontFamily: ${userAppSettings.displaySettings.fontFamily}', + ) + ..info( + '_onAppSettingsRefreshed: userAppSettings.fontWeight: ${userAppSettings.displaySettings.fontWeight}', + ) + ..info( + '_onAppSettingsRefreshed: newFontFamily mapped to: $newFontFamily', + ); emit( state.copyWith( diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 39304817..79d6bf23 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -4,9 +4,8 @@ import 'package:data_repository/data_repository.dart'; import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/ads/ad_navigator_observer.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/ad_service.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/config/app_environment.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/services/app_status_service.dart'; @@ -36,6 +35,7 @@ class App extends StatelessWidget { required AppEnvironment environment, required AdService adService, required DataRepository localAdRepository, + required GlobalKey navigatorKey, this.demoDataMigrationService, this.demoDataInitializerService, this.initialUser, @@ -52,7 +52,8 @@ class App extends StatelessWidget { _kvStorageService = kvStorageService, _environment = environment, _adService = adService, - _localAdRepository = localAdRepository; + _localAdRepository = localAdRepository, + _navigatorKey = navigatorKey; final AuthRepository _authenticationRepository; final DataRepository _headlinesRepository; @@ -68,6 +69,7 @@ class App extends StatelessWidget { final AppEnvironment _environment; final AdService _adService; final DataRepository _localAdRepository; + final GlobalKey _navigatorKey; final DemoDataMigrationService? demoDataMigrationService; final DemoDataInitializerService? demoDataInitializerService; final User? initialUser; @@ -103,6 +105,7 @@ class App extends StatelessWidget { demoDataInitializerService: demoDataInitializerService, initialUser: initialUser, adService: context.read(), + navigatorKey: _navigatorKey, // Pass navigatorKey to AppBloc ), ), BlocProvider( @@ -110,6 +113,16 @@ class App extends StatelessWidget { authenticationRepository: context.read(), ), ), + // Provide the InterstitialAdManager as a RepositoryProvider + // it depends on the state managed by AppBloc. Therefore, + // so it must be created after AppBloc is available. + RepositoryProvider( + create: (context) => InterstitialAdManager( + appBloc: context.read(), + adService: context.read(), + ), + lazy: false, // Ensure it's created immediately + ), ], child: _AppView( authenticationRepository: _authenticationRepository, @@ -124,6 +137,7 @@ class App extends StatelessWidget { environment: _environment, adService: _adService, localAdRepository: _localAdRepository, + navigatorKey: _navigatorKey, // Pass navigatorKey to _AppView ), ), ); @@ -144,6 +158,7 @@ class _AppView extends StatefulWidget { required this.environment, required this.adService, required this.localAdRepository, + required this.navigatorKey, }); final AuthRepository authenticationRepository; @@ -158,6 +173,7 @@ class _AppView extends StatefulWidget { final AppEnvironment environment; final AdService adService; final DataRepository localAdRepository; + final GlobalKey navigatorKey; @override State<_AppView> createState() => _AppViewState(); @@ -165,12 +181,8 @@ class _AppView extends StatefulWidget { class _AppViewState extends State<_AppView> { late final GoRouter _router; - // Standard notifier that GoRouter listens to. late final ValueNotifier _statusNotifier; - // The service responsible for automated status checks. AppStatusService? _appStatusService; - // The observer for handling interstitial ads on route changes. - AdNavigatorObserver? _adNavigatorObserver; @override void initState() { @@ -188,16 +200,6 @@ class _AppViewState extends State<_AppView> { environment: widget.environment, ); - // Derive AdThemeStyle from the current theme. - final adThemeStyle = AdThemeStyle.fromTheme(Theme.of(context)); - - // Initialize AdNavigatorObserver. - _adNavigatorObserver = AdNavigatorObserver( - appStateProvider: () => context.read().state, - adService: widget.adService, - adThemeStyle: adThemeStyle, - ); - _router = createRouter( authStatusNotifier: _statusNotifier, authenticationRepository: widget.authenticationRepository, @@ -211,7 +213,7 @@ class _AppViewState extends State<_AppView> { userRepository: widget.userRepository, environment: widget.environment, adService: widget.adService, - adNavigatorObserver: _adNavigatorObserver!, // Pass the observer + navigatorKey: widget.navigatorKey, ); } @@ -220,13 +222,9 @@ class _AppViewState extends State<_AppView> { _statusNotifier.dispose(); // Dispose the AppStatusService to cancel timers and remove observers. _appStatusService?.dispose(); - // AdNavigatorObserver does not need explicit dispose here as it's a NavigatorObserver - // and its internal resources are managed by the AdService/AdMob SDK. super.dispose(); } - // Removed _initDynamicLinks and _handleDynamicLink methods - @override Widget build(BuildContext context) { // Wrap the part of the tree that needs to react to AppBloc state changes diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index fd3a4ee8..cdd677e0 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -137,6 +137,10 @@ Future bootstrap( // This ensures the AppBloc starts with an accurate authentication status. final initialUser = await authenticationRepository.getCurrentUser(); + // Create a GlobalKey for the NavigatorState to be used by AppBloc + // and InterstitialAdManager for BuildContext access. + final navigatorKey = GlobalKey(); + // 4. Initialize all other DataClients and Repositories. // These now also have a guaranteed valid httpClient. DataClient headlinesClient; @@ -417,5 +421,6 @@ Future bootstrap( adService: adService, initialUser: initialUser, localAdRepository: localAdRepository, + navigatorKey: navigatorKey, // Pass the navigatorKey to App ); } diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index eb8d00df..df02ef2c 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -4,6 +4,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/ad_service.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_placeholder.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/widgets/feed_ad_loader_widget.dart'; @@ -324,29 +325,44 @@ class _EntityDetailsViewState extends State { case HeadlineImageStyle.hidden: tile = HeadlineTileTextOnly( headline: item, - onHeadlineTap: () => context.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': item.id}, - extra: item, - ), + onHeadlineTap: () { + context + .read() + .onPotentialAdTrigger(context: context); + context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ); + }, ); case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: item, - onHeadlineTap: () => context.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': item.id}, - extra: item, - ), + onHeadlineTap: () { + context + .read() + .onPotentialAdTrigger(context: context); + context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ); + }, ); case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: item, - onHeadlineTap: () => context.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': item.id}, - extra: item, - ), + onHeadlineTap: () { + context + .read() + .onPotentialAdTrigger(context: context); + context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ); + }, ); } return tile; diff --git a/lib/headline-details/view/headline_details_page.dart b/lib/headline-details/view/headline_details_page.dart index e9d03c3a..223ebff1 100644 --- a/lib/headline-details/view/headline_details_page.dart +++ b/lib/headline-details/view/headline_details_page.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/ad_service.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/widgets/in_article_ad_loader_widget.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; @@ -312,9 +313,6 @@ class _HeadlineDetailsPageState extends State { ), ), ), - ]; - - slivers.addAll([ SliverPadding( padding: horizontalPadding.copyWith(top: AppSpacing.lg), sliver: SliverToBoxAdapter( @@ -338,7 +336,7 @@ class _HeadlineDetailsPageState extends State { ), ), ), - ]); + ]; // Add ad above continue reading button if configured if (adConfig != null && @@ -521,6 +519,9 @@ class _HeadlineDetailsPageState extends State { ..add( InkWell( onTap: () { + context.read().onPotentialAdTrigger( + context: context, + ); context.pushNamed( Routes.entityDetailsName, pathParameters: { @@ -549,6 +550,9 @@ class _HeadlineDetailsPageState extends State { ..add( InkWell( onTap: () { + context.read().onPotentialAdTrigger( + context: context, + ); context.pushNamed( Routes.entityDetailsName, pathParameters: { @@ -577,6 +581,9 @@ class _HeadlineDetailsPageState extends State { ..add( InkWell( onTap: () { + context.read().onPotentialAdTrigger( + context: context, + ); context.pushNamed( Routes.entityDetailsName, pathParameters: { @@ -662,29 +669,44 @@ class _HeadlineDetailsPageState extends State { case HeadlineImageStyle.hidden: tile = HeadlineTileTextOnly( headline: similarHeadline, - onHeadlineTap: () => context.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': similarHeadline.id}, - extra: similarHeadline, - ), + onHeadlineTap: () { + context + .read() + .onPotentialAdTrigger(context: context); + context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': similarHeadline.id}, + extra: similarHeadline, + ); + }, ); case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: similarHeadline, - onHeadlineTap: () => context.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': similarHeadline.id}, - extra: similarHeadline, - ), + onHeadlineTap: () { + context + .read() + .onPotentialAdTrigger(context: context); + context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': similarHeadline.id}, + extra: similarHeadline, + ); + }, ); case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: similarHeadline, - onHeadlineTap: () => context.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': similarHeadline.id}, - extra: similarHeadline, - ), + onHeadlineTap: () { + context + .read() + .onPotentialAdTrigger(context: context); + context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': similarHeadline.id}, + extra: similarHeadline, + ); + }, ); } return tile; diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index 4d2d0649..2aa3580d 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/account_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/ad_service.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_placeholder.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/widgets/feed_ad_loader_widget.dart'; @@ -296,29 +297,44 @@ class _HeadlinesFeedPageState extends State { case HeadlineImageStyle.hidden: tile = HeadlineTileTextOnly( headline: item, - onHeadlineTap: () => context.goNamed( - Routes.articleDetailsName, - pathParameters: {'id': item.id}, - extra: item, - ), + onHeadlineTap: () { + context + .read() + .onPotentialAdTrigger(context: context); + context.goNamed( + Routes.articleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ); + }, ); case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: item, - onHeadlineTap: () => context.goNamed( - Routes.articleDetailsName, - pathParameters: {'id': item.id}, - extra: item, - ), + onHeadlineTap: () { + context + .read() + .onPotentialAdTrigger(context: context); + context.goNamed( + Routes.articleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ); + }, ); case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: item, - onHeadlineTap: () => context.goNamed( - Routes.articleDetailsName, - pathParameters: {'id': item.id}, - extra: item, - ), + onHeadlineTap: () { + context + .read() + .onPotentialAdTrigger(context: context); + context.goNamed( + Routes.articleDetailsName, + pathParameters: {'id': item.id}, + extra: item, + ); + }, ); } return tile; diff --git a/lib/headlines-search/view/headlines_search_page.dart b/lib/headlines-search/view/headlines_search_page.dart index 87b04e64..d61de4ae 100644 --- a/lib/headlines-search/view/headlines_search_page.dart +++ b/lib/headlines-search/view/headlines_search_page.dart @@ -6,6 +6,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/ad_service.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_placeholder.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/widgets/feed_ad_loader_widget.dart'; @@ -313,29 +314,44 @@ class _HeadlinesSearchViewState extends State<_HeadlinesSearchView> { case HeadlineImageStyle.hidden: tile = HeadlineTileTextOnly( headline: feedItem, - onHeadlineTap: () => context.goNamed( - Routes.searchArticleDetailsName, - pathParameters: {'id': feedItem.id}, - extra: feedItem, - ), + onHeadlineTap: () { + context + .read() + .onPotentialAdTrigger(context: context); + context.goNamed( + Routes.searchArticleDetailsName, + pathParameters: {'id': feedItem.id}, + extra: feedItem, + ); + }, ); case HeadlineImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: feedItem, - onHeadlineTap: () => context.goNamed( - Routes.searchArticleDetailsName, - pathParameters: {'id': feedItem.id}, - extra: feedItem, - ), + onHeadlineTap: () { + context + .read() + .onPotentialAdTrigger(context: context); + context.goNamed( + Routes.searchArticleDetailsName, + pathParameters: {'id': feedItem.id}, + extra: feedItem, + ); + }, ); case HeadlineImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: feedItem, - onHeadlineTap: () => context.goNamed( - Routes.searchArticleDetailsName, - pathParameters: {'id': feedItem.id}, - extra: feedItem, - ), + onHeadlineTap: () { + context + .read() + .onPotentialAdTrigger(context: context); + context.goNamed( + Routes.searchArticleDetailsName, + pathParameters: {'id': feedItem.id}, + extra: feedItem, + ); + }, ); } return tile; diff --git a/lib/headlines-search/widgets/country_item_widget.dart b/lib/headlines-search/widgets/country_item_widget.dart index 6ad0ec86..5b91ba89 100644 --- a/lib/headlines-search/widgets/country_item_widget.dart +++ b/lib/headlines-search/widgets/country_item_widget.dart @@ -1,5 +1,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; import 'package:go_router/go_router.dart'; @@ -20,6 +22,9 @@ class CountryItemWidget extends StatelessWidget { ? Text(country.isoCode, maxLines: 1, overflow: TextOverflow.ellipsis) : null, onTap: () { + context.read().onPotentialAdTrigger( + context: context, + ); context.pushNamed( Routes.entityDetailsName, pathParameters: {'type': ContentType.country.name, 'id': country.id}, diff --git a/lib/headlines-search/widgets/source_item_widget.dart b/lib/headlines-search/widgets/source_item_widget.dart index 730ee75e..2896a90c 100644 --- a/lib/headlines-search/widgets/source_item_widget.dart +++ b/lib/headlines-search/widgets/source_item_widget.dart @@ -1,5 +1,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; import 'package:go_router/go_router.dart'; @@ -21,6 +23,9 @@ class SourceItemWidget extends StatelessWidget { ) : null, onTap: () { + context.read().onPotentialAdTrigger( + context: context, + ); context.pushNamed( Routes.entityDetailsName, pathParameters: {'type': ContentType.source.name, 'id': source.id}, diff --git a/lib/headlines-search/widgets/topic_item_widget.dart b/lib/headlines-search/widgets/topic_item_widget.dart index 1114ce10..f5f75b55 100644 --- a/lib/headlines-search/widgets/topic_item_widget.dart +++ b/lib/headlines-search/widgets/topic_item_widget.dart @@ -1,5 +1,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; import 'package:go_router/go_router.dart'; @@ -21,6 +23,9 @@ class TopicItemWidget extends StatelessWidget { ) : null, onTap: () { + context.read().onPotentialAdTrigger( + context: context, + ); context.pushNamed( Routes.entityDetailsName, pathParameters: {'type': ContentType.topic.name, 'id': topic.id}, diff --git a/lib/router/router.dart b/lib/router/router.dart index 0817343d..b72709f6 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -13,7 +13,6 @@ import 'package:flutter_news_app_mobile_client_full_source_code/account/view/man import 'package:flutter_news_app_mobile_client_full_source_code/account/view/manage_followed_items/topics/add_topic_to_follow_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/view/manage_followed_items/topics/followed_topics_list_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/view/saved_headlines_page.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/ads/ad_navigator_observer.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/ad_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; @@ -71,8 +70,7 @@ GoRouter createRouter({ required DataRepository userRepository, required local_config.AppEnvironment environment, required AdService adService, - required AdNavigatorObserver - adNavigatorObserver, // Accept AdNavigatorObserver + required GlobalKey navigatorKey, // Add navigatorKey }) { // Instantiate AccountBloc once to be shared final accountBloc = AccountBloc( @@ -95,7 +93,9 @@ GoRouter createRouter({ initialLocation: '/', debugLogDiagnostics: true, observers: [ - adNavigatorObserver, // Pass the AdNavigatorObserver to GoRouter + GoRouterObserver( + navigatorKey: navigatorKey, + ), // Use GoRouterObserver with navigatorKey ], // --- Redirect Logic --- redirect: (BuildContext context, GoRouterState state) { @@ -402,7 +402,6 @@ GoRouter createRouter({ ); }, ), - // Removed separate AccountBloc creation here ], child: AppShell(navigationShell: navigationShell), ); @@ -449,7 +448,7 @@ GoRouter createRouter({ ); }, ), - // Sub-route for notifications (placeholder) - MOVED HERE + // Sub-route for notifications (placeholder) GoRoute( path: Routes.notifications, name: Routes.notificationsName, @@ -500,8 +499,7 @@ GoRouter createRouter({ create: (context) => SourcesFilterBloc( sourcesRepository: context .read>(), - countriesRepository: // Added missing repository - context + countriesRepository: context .read>(), userContentPreferencesRepository: context .read>(), @@ -528,7 +526,6 @@ GoRouter createRouter({ final initialSelection = state.extra as List?; return MaterialPage( - // fullscreenDialog: true, child: BlocProvider( create: (context) => CountriesFilterBloc( countriesRepository: context @@ -786,3 +783,17 @@ GoRouter createRouter({ ], ); } + +/// A custom [NavigatorObserver] that provides access to the [NavigatorState] +/// via a [GlobalKey]. +/// +/// This is used to obtain a [BuildContext] for services that need to interact +/// with the widget tree (e.g., showing dialogs) but are not directly part +/// of the tree themselves. +class GoRouterObserver extends NavigatorObserver { + /// Creates a [GoRouterObserver]. + GoRouterObserver({required this.navigatorKey}); + + /// The [GlobalKey] used to access the [NavigatorState]. + final GlobalKey navigatorKey; +} diff --git a/lib/status/view/update_required_page.dart b/lib/status/view/update_required_page.dart index eb7661b6..cd85ae4b 100644 --- a/lib/status/view/update_required_page.dart +++ b/lib/status/view/update_required_page.dart @@ -38,6 +38,7 @@ class UpdateRequiredPage extends StatelessWidget { await launchUrl(uri, mode: LaunchMode.externalApplication); } else { // If the URL can't be launched, inform the user. + // ignore: use_build_context_synchronously ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(