diff --git a/analysis_options.yaml b/analysis_options.yaml index 47584577..6659894d 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,6 +9,7 @@ analyzer: flutter_style_todos: ignore lines_longer_than_80_chars: ignore prefer_asserts_with_message: ignore + use_build_context_synchronously: ignore use_if_null_to_convert_nulls_to_bools: ignore include: package:very_good_analysis/analysis_options.7.0.0.yaml linter: diff --git a/lib/account/bloc/in_app_notification_center_state.dart b/lib/account/bloc/in_app_notification_center_state.dart index ba3a1842..f0e36559 100644 --- a/lib/account/bloc/in_app_notification_center_state.dart +++ b/lib/account/bloc/in_app_notification_center_state.dart @@ -92,7 +92,7 @@ class InAppNotificationCenterState extends Equatable { breakingNewsCursor ?? Object(), digestHasMore, digestCursor ?? Object(), - error ?? Object(), // Include error in props, handle nullability + error ?? Object(), ]; /// Creates a copy of this state with the given fields replaced with the new diff --git a/lib/account/view/account_page.dart b/lib/account/view/account_page.dart index 36a2bc23..5a1696c4 100644 --- a/lib/account/view/account_page.dart +++ b/lib/account/view/account_page.dart @@ -1,4 +1,4 @@ -import 'package:core/core.dart' hide AppStatus; +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/app/bloc/app_bloc.dart'; @@ -44,20 +44,18 @@ class AccountPage extends StatelessWidget { // This declutters the main content card and follows common UI patterns. if (isAnonymous) IconButton( - icon: const Icon(Icons.login), + icon: const Icon(Icons.sync), tooltip: l10n.anonymousLimitButton, onPressed: () => context.goNamed(Routes.accountLinkingName), ) else IconButton( - icon: const Icon(Icons.logout), // Non-directional icon for logout + icon: const Icon(Icons.logout), tooltip: l10n.accountSignOutTile, onPressed: () => context.read().add(const AppLogoutRequested()), ), - const SizedBox( - width: AppSpacing.lg, - ), // Consistent right padding for the AppBar actions + const SizedBox(width: AppSpacing.lg), ], ), body: SingleChildScrollView( diff --git a/lib/account/view/in_app_notification_center_page.dart b/lib/account/view/in_app_notification_center_page.dart index e11c28f7..afea4b62 100644 --- a/lib/account/view/in_app_notification_center_page.dart +++ b/lib/account/view/in_app_notification_center_page.dart @@ -3,10 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/in_app_notification_center_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/widgets/in_app_notification_list_item.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/ads/services/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'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/headline_tap_handler.dart'; import 'package:ui_kit/ui_kit.dart'; /// {@template in_app_notification_center_page} @@ -295,19 +293,14 @@ class _NotificationListState extends State<_NotificationList> { ); final payload = notification.payload; - final contentType = payload.data['contentType'] as String?; - final id = payload.data['headlineId'] as String?; + final contentType = payload.contentType; + final contentId = payload.contentId; - if (contentType == 'headline' && id != null) { - await context - .read() - .onPotentialAdTrigger(); - - if (!context.mounted) return; - - await context.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': id}, + if (contentType == ContentType.headline && contentId.isNotEmpty) { + // Use the handler to fetch the headline by ID and open it. + await HeadlineTapHandler.handleHeadlineTapById( + context, + contentId, ); } }, diff --git a/lib/account/view/saved_headlines_page.dart b/lib/account/view/saved_headlines_page.dart index 70625f67..450b4712 100644 --- a/lib/account/view/saved_headlines_page.dart +++ b/lib/account/view/saved_headlines_page.dart @@ -1,11 +1,9 @@ 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/services/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/models/app_life_cycle_status.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:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/feed_core.dart'; import 'package:go_router/go_router.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -25,7 +23,6 @@ class SavedHeadlinesPage extends StatelessWidget { final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); final textTheme = theme.textTheme; - final colorScheme = theme.colorScheme; return Scaffold( appBar: AppBar( @@ -74,21 +71,6 @@ class SavedHeadlinesPage extends StatelessWidget { ); } - Future onHeadlineTap(Headline headline) async { - // Await for the ad to be shown and dismissed. - await context.read().onPotentialAdTrigger(); - - // Check if the widget is still in the tree before navigating. - if (!context.mounted) return; - - // Proceed with navigation after the ad is closed. - await context.pushNamed( - Routes.accountArticleDetailsName, - pathParameters: {'id': headline.id}, - extra: headline, - ); - } - return ListView.separated( padding: const EdgeInsets.symmetric( vertical: AppSpacing.paddingSmall, @@ -102,48 +84,28 @@ class SavedHeadlinesPage extends StatelessWidget { itemBuilder: (context, index) { final headline = savedHeadlines[index]; final imageStyle = - appState.settings?.feedPreferences.headlineImageStyle ?? - HeadlineImageStyle.smallThumbnail; - - final trailingButton = IconButton( - icon: Icon(Icons.delete_outline, color: colorScheme.error), - tooltip: l10n.headlineDetailsRemoveFromSavedTooltip, - onPressed: () { - final updatedSavedHeadlines = List.from( - savedHeadlines, - )..removeWhere((h) => h.id == headline.id); - - final updatedPreferences = userContentPreferences.copyWith( - savedHeadlines: updatedSavedHeadlines, - ); - - context.read().add( - AppUserContentPreferencesChanged( - preferences: updatedPreferences, - ), - ); - }, - ); + appState.settings?.feedSettings.feedItemImageStyle ?? + FeedItemImageStyle.smallThumbnail; Widget tile; switch (imageStyle) { - case HeadlineImageStyle.hidden: + case FeedItemImageStyle.hidden: tile = HeadlineTileTextOnly( headline: headline, - onHeadlineTap: () => onHeadlineTap(headline), - trailing: trailingButton, + onHeadlineTap: () => + HeadlineTapHandler.handleHeadlineTap(context, headline), ); - case HeadlineImageStyle.smallThumbnail: + case FeedItemImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: headline, - onHeadlineTap: () => onHeadlineTap(headline), - trailing: trailingButton, + onHeadlineTap: () => + HeadlineTapHandler.handleHeadlineTap(context, headline), ); - case HeadlineImageStyle.largeThumbnail: + case FeedItemImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: headline, - onHeadlineTap: () => onHeadlineTap(headline), - trailing: trailingButton, + onHeadlineTap: () => + HeadlineTapHandler.handleHeadlineTap(context, headline), ); } return tile; diff --git a/lib/account/widgets/in_app_notification_list_item.dart b/lib/account/widgets/in_app_notification_list_item.dart index a5fcabd8..9264349e 100644 --- a/lib/account/widgets/in_app_notification_list_item.dart +++ b/lib/account/widgets/in_app_notification_list_item.dart @@ -6,7 +6,7 @@ import 'package:ui_kit/ui_kit.dart'; /// {@template in_app_notification_list_item} /// A widget that displays a single in-app notification in a list. /// -/// It shows the notification's title, body, and the time it was received. +/// It shows the notification's title and the time it was received. /// Unread notifications are visually distinguished with a leading dot and /// a bolder title. /// {@endtemplate} @@ -50,23 +50,11 @@ class InAppNotificationListItem extends StatelessWidget { fontWeight: isUnread ? FontWeight.bold : FontWeight.normal, ), ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - notification.payload.body, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: AppSpacing.xs), - Text( - timeago.format(notification.createdAt), - style: textTheme.bodySmall, - ), - ], + subtitle: Text( + timeago.format(notification.createdAt), + style: textTheme.bodySmall, ), onTap: onTap, - isThreeLine: true, ); } } diff --git a/lib/ads/providers/ad_provider.dart b/lib/ads/providers/ad_provider.dart index 0027edb0..c9e18685 100644 --- a/lib/ads/providers/ad_provider.dart +++ b/lib/ads/providers/ad_provider.dart @@ -30,13 +30,13 @@ abstract class AdProvider { /// The [adPlatformIdentifiers] provides the platform-specific ad unit IDs. /// The [adId] is the specific identifier for the ad slot (e.g., native ad unit ID). /// The [adThemeStyle] provides UI-agnostic theme properties for ad styling. - /// The [headlineImageStyle] provides the user's preference for feed layout, + /// The [feedItemImageStyle] provides the user's preference for feed layout, /// which can be used to request an appropriately sized ad. Future loadNativeAd({ required AdPlatformIdentifiers adPlatformIdentifiers, required String? adId, required AdThemeStyle adThemeStyle, - HeadlineImageStyle? headlineImageStyle, + FeedItemImageStyle? feedItemImageStyle, }); /// Loads an inline banner ad. @@ -48,13 +48,13 @@ abstract class AdProvider { /// The [adPlatformIdentifiers] provides the platform-specific ad unit IDs. /// The [adId] is the specific identifier for the ad slot (e.g., banner ad unit ID). /// The [adThemeStyle] provides UI-agnostic theme properties for ad styling. - /// The [headlineImageStyle] provides the user's preference for feed layout, + /// The [feedItemImageStyle] provides the user's preference for feed layout, /// which can be used to request an appropriately sized ad. Future loadBannerAd({ required AdPlatformIdentifiers adPlatformIdentifiers, required String? adId, required AdThemeStyle adThemeStyle, - HeadlineImageStyle? headlineImageStyle, + FeedItemImageStyle? feedItemImageStyle, }); /// Loads a full-screen interstitial ad. diff --git a/lib/ads/providers/admob_ad_provider.dart b/lib/ads/providers/admob_ad_provider.dart index f0c649b2..9d822cc2 100644 --- a/lib/ads/providers/admob_ad_provider.dart +++ b/lib/ads/providers/admob_ad_provider.dart @@ -50,7 +50,7 @@ class AdMobAdProvider implements AdProvider { required AdPlatformIdentifiers adPlatformIdentifiers, required String? adId, required AdThemeStyle adThemeStyle, - HeadlineImageStyle? headlineImageStyle, + FeedItemImageStyle? feedItemImageStyle, }) async { _logger.info('AdMobAdProvider: loadNativeAd called for adId: $adId'); if (adId == null || adId.isEmpty) { @@ -65,7 +65,7 @@ class AdMobAdProvider implements AdProvider { ); // Determine the template type based on the user's feed style preference. - final templateType = headlineImageStyle == HeadlineImageStyle.largeThumbnail + final templateType = feedItemImageStyle == FeedItemImageStyle.largeThumbnail ? NativeAdTemplateType.medium : NativeAdTemplateType.small; @@ -149,7 +149,7 @@ class AdMobAdProvider implements AdProvider { required AdPlatformIdentifiers adPlatformIdentifiers, required String? adId, required AdThemeStyle adThemeStyle, - HeadlineImageStyle? headlineImageStyle, + FeedItemImageStyle? feedItemImageStyle, }) async { _logger.info('AdMobAdProvider: loadBannerAd called for adId: $adId'); if (adId == null || adId.isEmpty) { @@ -164,7 +164,7 @@ class AdMobAdProvider implements AdProvider { ); // Determine the ad size based on the user's feed style preference. - final adSize = headlineImageStyle == HeadlineImageStyle.largeThumbnail + final adSize = feedItemImageStyle == FeedItemImageStyle.largeThumbnail ? admob.AdSize.mediumRectangle : admob.AdSize.banner; diff --git a/lib/ads/providers/demo_ad_provider.dart b/lib/ads/providers/demo_ad_provider.dart index e0bba93e..4c21d84c 100644 --- a/lib/ads/providers/demo_ad_provider.dart +++ b/lib/ads/providers/demo_ad_provider.dart @@ -35,7 +35,7 @@ class DemoAdProvider implements AdProvider { required AdPlatformIdentifiers adPlatformIdentifiers, required String? adId, required AdThemeStyle adThemeStyle, - HeadlineImageStyle? headlineImageStyle, + FeedItemImageStyle? feedItemImageStyle, }) async { _logger.info('Simulating native ad load for demo environment.'); // Simulate a delay for loading. @@ -45,7 +45,7 @@ class DemoAdProvider implements AdProvider { id: _uuid.v4(), provider: AdPlatformType.demo, adObject: Object(), - templateType: headlineImageStyle == HeadlineImageStyle.largeThumbnail + templateType: feedItemImageStyle == FeedItemImageStyle.largeThumbnail ? NativeAdTemplateType.medium : NativeAdTemplateType.small, ); @@ -56,7 +56,7 @@ class DemoAdProvider implements AdProvider { required AdPlatformIdentifiers adPlatformIdentifiers, required String? adId, required AdThemeStyle adThemeStyle, - HeadlineImageStyle? headlineImageStyle, + FeedItemImageStyle? feedItemImageStyle, }) async { _logger.info('Simulating banner ad load for demo environment.'); // Simulate a delay for loading. diff --git a/lib/ads/services/ad_service.dart b/lib/ads/services/ad_service.dart index 5e094c4d..47366218 100644 --- a/lib/ads/services/ad_service.dart +++ b/lib/ads/services/ad_service.dart @@ -107,16 +107,15 @@ class AdService { required AdType adType, required AdThemeStyle adThemeStyle, required AppUserRole userRole, - HeadlineImageStyle? headlineImageStyle, + FeedItemImageStyle? feedItemImageStyle, }) async { _logger.info('AdService: getFeedAd called for adType: $adType'); return _loadInlineAd( adConfig: adConfig, adType: adType, adThemeStyle: adThemeStyle, - feedAd: true, - headlineImageStyle: headlineImageStyle, userRole: userRole, + feedItemImageStyle: feedItemImageStyle, ); } @@ -143,8 +142,7 @@ class AdService { return null; } - // Check if interstitial ads are enabled for the current user role. - final interstitialConfig = adConfig.interstitialAdConfiguration; + final interstitialConfig = adConfig.navigationAdConfiguration; // Check if the interstitial ads are globally enabled AND if the current // user role has a defined configuration in the visibleTo map. final isInterstitialEnabledForRole = @@ -191,7 +189,7 @@ class AdService { } // Use the correct interstitial ad ID from AdPlatformIdentifiers - final adId = platformAdIdentifiers.feedToArticleInterstitialAdId; + final adId = platformAdIdentifiers.interstitialAdId; if (adId == null || adId.isEmpty) { _logger.warning( @@ -227,36 +225,6 @@ class AdService { } } - /// Retrieves a loaded inline ad (native or banner) for an in-article placement. - /// - /// This method delegates ad loading to the appropriate [AdProvider] based on - /// the [adConfig]'s `primaryAdPlatform` and the `defaultInArticleAdType` - /// from the `articleAdConfiguration`. - /// - /// Returns an [InlineAd] if an ad is available, otherwise `null`. - /// - /// - [adConfig]: The remote configuration for ad display rules. - /// - [adThemeStyle]: UI-agnostic theme properties for ad styling. - /// - [userRole]: The current role of the user, used to determine ad visibility. - /// - [slotType]: The specific in-article ad slot type. - Future getInArticleAd({ - required AdConfig adConfig, - required AdThemeStyle adThemeStyle, - required AppUserRole userRole, - required InArticleAdSlotType slotType, - }) async { - _logger.info('AdService: getInArticleAd called.'); - return _loadInlineAd( - adConfig: adConfig, - adType: AdType.banner, - adThemeStyle: adThemeStyle, - feedAd: false, - bannerAdShape: adConfig.articleAdConfiguration.bannerAdShape, - userRole: userRole, - slotType: slotType, - ); - } - /// Private helper method to consolidate logic for loading inline ads (native/banner). /// /// This method handles the common steps of checking ad enablement, selecting @@ -266,60 +234,33 @@ class AdService { /// - [adConfig]: The remote configuration for ad display rules. /// - [adType]: The specific type of inline ad to load ([AdType.native] or [AdType.banner]). /// - [adThemeStyle]: UI-agnostic theme properties for ad styling. - /// - [feedAd]: A boolean indicating if this is for a feed ad (true) or in-article ad (false). - /// - [headlineImageStyle]: The user's preference for feed layout, + /// - [feedItemImageStyle]: The user's preference for feed layout, /// which can be used to request an appropriately sized ad. - /// - [bannerAdShape]: The preferred shape for banner ads, used for in-article banners. /// - [userRole]: The current role of the user, used to determine ad visibility. - /// - [slotType]: The specific in-article ad slot type, used for in-article ads. /// /// Returns an [InlineAd] if an ad is successfully loaded, otherwise `null`. Future _loadInlineAd({ required AdConfig adConfig, required AdType adType, required AdThemeStyle adThemeStyle, - required bool feedAd, required AppUserRole userRole, - HeadlineImageStyle? headlineImageStyle, - BannerAdShape? bannerAdShape, - InArticleAdSlotType? slotType, + FeedItemImageStyle? feedItemImageStyle, }) async { - _logger.info( - 'AdService: _loadInlineAd called for adType: $adType, feedAd: $feedAd', - ); + _logger.info('AdService: _loadInlineAd called for adType: $adType'); // Check if ads are globally enabled. if (!adConfig.enabled) { _logger.info('AdService: Ads are globally disabled in RemoteConfig.'); return null; } - // Check if ads are enabled for the specific context and user role. - var isContextEnabled = false; - if (feedAd) { - final feedAdConfig = adConfig.feedAdConfiguration; - // Check if feed ads are globally enabled AND if the current user role - // has a defined configuration in the visibleTo map. - isContextEnabled = - feedAdConfig.enabled && feedAdConfig.visibleTo.containsKey(userRole); - } else { - // For in-article ads, check global article ad enablement and then - // specific slot enablement for the user role. - final articleAdConfig = adConfig.articleAdConfiguration; - final isArticleAdEnabledForRole = articleAdConfig.visibleTo.containsKey( - userRole, - ); - final isSlotEnabledForRole = - articleAdConfig.visibleTo[userRole]?[slotType] ?? false; - isContextEnabled = - articleAdConfig.enabled && - isArticleAdEnabledForRole && - isSlotEnabledForRole; - } + final feedAdConfig = adConfig.feedAdConfiguration; + final isFeedAdEnabledForRole = + feedAdConfig.enabled && feedAdConfig.visibleTo.containsKey(userRole); - if (!isContextEnabled) { + if (!isFeedAdEnabledForRole) { _logger.info( - 'AdService: Ads are disabled for current context (feedAd: $feedAd, ' - 'slotType: $slotType) and user role $userRole in RemoteConfig.', + 'AdService: Feed ads are disabled for user role $userRole ' + 'or globally in RemoteConfig.', ); return null; } @@ -364,18 +305,13 @@ class AdService { return null; } - final adId = feedAd - ? (adType == AdType.native - ? platformAdIdentifiers.feedNativeAdId - : platformAdIdentifiers.feedBannerAdId) - : (adType == AdType.native - ? platformAdIdentifiers.inArticleNativeAdId - : platformAdIdentifiers.inArticleBannerAdId); + final adId = adType == AdType.native + ? platformAdIdentifiers.nativeAdId + : platformAdIdentifiers.bannerAdId; if (adId == null || adId.isEmpty) { _logger.warning( - 'AdService: No ad ID configured for platform $primaryAdPlatform and ad type $adType ' - 'for ${feedAd ? 'feed' : 'in-article'} placement.', + 'AdService: No ad ID configured for platform $primaryAdPlatform and ad type $adType.', ); return null; } @@ -383,21 +319,16 @@ class AdService { for (var attempt = 0; attempt <= _maxAdLoadRetries; attempt++) { if (attempt > 0) { _logger.info( - 'AdService: Retrying $adType ad load (attempt $attempt) for ID: $adId ' - 'after $_adLoadRetryDelay delay.', + 'AdService: Retrying $adType ad load (attempt $attempt) for ID: $adId after $_adLoadRetryDelay delay.', ); await Future.delayed(_adLoadRetryDelay); } try { _logger.info( - 'AdService: Requesting $adType ad from $primaryAdPlatform AdProvider with ID: $adId ' - 'for ${feedAd ? 'feed' : 'in-article'} placement.', + 'AdService: Requesting $adType ad from $primaryAdPlatform AdProvider with ID: $adId.', ); InlineAd? loadedAd; - // For in-article banner ads, bannerAdShape dictates the visual style. - // For feed ads, headlineImageStyle is still relevant. - final effectiveHeadlineImageStyle = feedAd ? headlineImageStyle : null; switch (adType) { case AdType.native: @@ -405,14 +336,14 @@ class AdService { adPlatformIdentifiers: platformAdIdentifiers, adId: adId, adThemeStyle: adThemeStyle, - headlineImageStyle: effectiveHeadlineImageStyle, + feedItemImageStyle: feedItemImageStyle, ); case AdType.banner: loadedAd = await adProvider.loadBannerAd( adPlatformIdentifiers: platformAdIdentifiers, adId: adId, adThemeStyle: adThemeStyle, - headlineImageStyle: effectiveHeadlineImageStyle, + feedItemImageStyle: feedItemImageStyle, ); case AdType.interstitial: case AdType.video: @@ -458,7 +389,7 @@ class AdService { /// [feedItems]: The list of feed items (headlines, other decorators) /// to inject ad placeholders into. /// [user]: The current authenticated user, used to determine ad configuration. - /// [adConfig]: The remote configuration for ad display rules. + /// [remoteConfig]: The remote configuration for ad display rules. /// [imageStyle]: The desired image style for the ad, used to determine /// the placeholder's template type. /// [adThemeStyle]: The current theme style for ads, passed through to the @@ -469,16 +400,16 @@ class AdService { /// across pagination. /// /// Returns a new list of [FeedItem] objects, interspersed with ad placeholders. - Future> injectAdPlaceholders({ + Future> injectFeedAdPlaceholders({ required List feedItems, required User? user, - required AdConfig adConfig, - required HeadlineImageStyle imageStyle, + required RemoteConfig remoteConfig, + required FeedItemImageStyle imageStyle, required AdThemeStyle adThemeStyle, int processedContentItemCount = 0, }) async { // If feed ads are not enabled in the remote config, return the original list. - if (!adConfig.feedAdConfiguration.enabled) { + if (!remoteConfig.features.ads.feedAdConfiguration.enabled) { return feedItems; } @@ -486,7 +417,7 @@ class AdService { // Determine ad frequency rules based on user role. final feedAdFrequencyConfig = - adConfig.feedAdConfiguration.visibleTo[userRole]; + remoteConfig.features.ads.feedAdConfiguration.visibleTo[userRole]; // Default to 0 for adFrequency and adPlacementInterval if no config is found // for the user role, effectively disabling ads for that role. @@ -505,9 +436,9 @@ class AdService { var currentContentItemCount = processedContentItemCount; // Get the primary ad platform and its identifiers - final primaryAdPlatform = adConfig.primaryAdPlatform; + final primaryAdPlatform = remoteConfig.features.ads.primaryAdPlatform; final platformAdIdentifiers = - adConfig.platformAdIdentifiers[primaryAdPlatform]; + remoteConfig.features.ads.platformAdIdentifiers[primaryAdPlatform]; if (platformAdIdentifiers == null) { _logger.warning( 'No AdPlatformIdentifiers found for primary platform: $primaryAdPlatform. ' @@ -517,7 +448,7 @@ class AdService { } // Get the ad type for feed ads (native or banner) - final feedAdType = adConfig.feedAdConfiguration.adType; + final feedAdType = remoteConfig.features.ads.feedAdConfiguration.adType; for (final item in feedItems) { result.add(item); @@ -541,9 +472,9 @@ class AdService { // Determine the specific ad ID based on the feed ad type. switch (feedAdType) { case AdType.native: - adIdentifier = platformAdIdentifiers.feedNativeAdId; + adIdentifier = platformAdIdentifiers.nativeAdId; case AdType.banner: - adIdentifier = platformAdIdentifiers.feedBannerAdId; + adIdentifier = platformAdIdentifiers.bannerAdId; case AdType.interstitial: case AdType.video: // Interstitial and video ads are not injected into the feed. diff --git a/lib/ads/services/interstitial_ad_manager.dart b/lib/ads/services/interstitial_ad_manager.dart index 50dbc7e0..05e060da 100644 --- a/lib/ads/services/interstitial_ad_manager.dart +++ b/lib/ads/services/interstitial_ad_manager.dart @@ -47,11 +47,14 @@ class InterstitialAdManager { /// The currently pre-loaded interstitial ad. InterstitialAd? _preloadedAd; - /// Tracks the number of eligible page transitions since the last ad was shown. - int _transitionCount = 0; + /// Tracks internal page transitions since the last internal ad was shown. + int _internalTransitionCount = 0; + + /// Tracks external URL navigations since the last external ad was shown. + int _externalTransitionCount = 0; /// The current remote configuration for ads. - AdConfig? _adConfig; + RemoteConfig? _remoteConfig; /// The current user role. AppUserRole? _userRole; @@ -64,14 +67,14 @@ class InterstitialAdManager { /// Handles changes in the [AppState]. void _onAppStateChanged(AppState state) { - final newAdConfig = state.remoteConfig?.adConfig; + final newRemoteConfig = state.remoteConfig; 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) { + if (newRemoteConfig != _remoteConfig || newUserRole != _userRole) { _logger.info('Ad config or user role changed. Updating internal state.'); - _adConfig = newAdConfig; + _remoteConfig = newRemoteConfig; _userRole = newUserRole; // A config change might mean we need to load an ad now. _maybePreloadAd(state); @@ -88,10 +91,10 @@ class InterstitialAdManager { return; } - final adConfig = _adConfig; - if (adConfig == null || - !adConfig.enabled || - !adConfig.interstitialAdConfiguration.enabled) { + final remoteConfig = _remoteConfig; + if (remoteConfig == null || + !remoteConfig.features.ads.enabled || + !remoteConfig.features.ads.navigationAdConfiguration.enabled) { _logger.info('Interstitial ads are disabled. Skipping pre-load.'); return; } @@ -125,7 +128,7 @@ class InterstitialAdManager { final adThemeStyle = AdThemeStyle.fromTheme(themeData); final ad = await _adService.getInterstitialAd( - adConfig: adConfig, + adConfig: remoteConfig.features.ads, adThemeStyle: adThemeStyle, userRole: _userRole ?? AppUserRole.guestUser, ); @@ -159,31 +162,84 @@ class InterstitialAdManager { /// Returns a [Future] that completes when the ad is dismissed, allowing the /// caller to await the ad's lifecycle before proceeding with navigation. Future onPotentialAdTrigger() async { - _transitionCount++; - _logger.info('Potential ad trigger. Transition count: $_transitionCount'); + _internalTransitionCount++; + _logger.info( + 'Internal navigation trigger. Count: $_internalTransitionCount', + ); - final adConfig = _adConfig; - if (adConfig == null) { - _logger.warning('No ad config available. Cannot determine ad frequency.'); + final remoteConfig = _remoteConfig; + if (remoteConfig == null) { + _logger.warning( + 'No remote config available. Cannot determine ad frequency.', + ); return; } - final frequencyConfig = - adConfig.interstitialAdConfiguration.visibleTo[_userRole]; + final frequencyConfig = remoteConfig + .features + .ads + .navigationAdConfiguration + .visibleTo[_userRole]; - // If no frequency config is found for the user role, or if it's explicitly - // disabled (transitionsBeforeShowingInterstitialAds == 0), then no ad should be shown. final requiredTransitions = - frequencyConfig?.transitionsBeforeShowingInterstitialAds ?? 0; + frequencyConfig?.internalNavigationsBeforeShowingInterstitialAd ?? 0; - if (requiredTransitions > 0 && _transitionCount >= requiredTransitions) { - _logger.info('Transition count meets threshold. Attempting to show ad.'); + if (requiredTransitions > 0 && + _internalTransitionCount >= requiredTransitions) { + _logger.info( + 'Internal transition count meets threshold. Attempting to show ad.', + ); + await _showAd(); + _internalTransitionCount = 0; + } else { + _logger.info( + 'Internal transition count ($_internalTransitionCount) has not met ' + 'threshold ($requiredTransitions).', + ); + } + } + + /// Called by the UI before an external navigation (opening a URL) occurs. + /// + /// This method increments the external navigation counter and shows a + /// pre-loaded ad if the frequency criteria are met. + /// + /// Returns a [Future] that completes when the ad is dismissed, allowing the + /// caller to await the ad's lifecycle before proceeding with navigation. + Future onExternalNavigationTrigger() async { + _externalTransitionCount++; + _logger.info( + 'External navigation trigger. Count: $_externalTransitionCount', + ); + + final remoteConfig = _remoteConfig; + if (remoteConfig == null) { + _logger.warning( + 'No remote config available. Cannot determine ad frequency.', + ); + return; + } + + final frequencyConfig = remoteConfig + .features + .ads + .navigationAdConfiguration + .visibleTo[_userRole]; + + final requiredTransitions = + frequencyConfig?.externalNavigationsBeforeShowingInterstitialAd ?? 0; + + if (requiredTransitions > 0 && + _externalTransitionCount >= requiredTransitions) { + _logger.info( + 'External navigation count meets threshold. Attempting to show ad.', + ); await _showAd(); - // Reset counter after showing (or attempting to show) - _transitionCount = 0; + _externalTransitionCount = 0; } else { _logger.info( - 'Transition count ($_transitionCount) has not met threshold ($requiredTransitions).', + 'External navigation count ($_externalTransitionCount) has not met ' + 'threshold ($requiredTransitions).', ); } } diff --git a/lib/ads/widgets/admob_inline_ad_widget.dart b/lib/ads/widgets/admob_inline_ad_widget.dart index 012f08c8..e1c999df 100644 --- a/lib/ads/widgets/admob_inline_ad_widget.dart +++ b/lib/ads/widgets/admob_inline_ad_widget.dart @@ -26,8 +26,7 @@ class AdmobInlineAdWidget extends StatelessWidget { /// {@macro admob_inline_ad_widget} const AdmobInlineAdWidget({ required this.inlineAd, - this.headlineImageStyle, - this.bannerAdShape, + this.feedItemImageStyle, super.key, }); @@ -37,10 +36,7 @@ class AdmobInlineAdWidget extends StatelessWidget { /// The user's preference for feed layout, used to determine the ad's visual /// size. This is only relevant for native ads. - final HeadlineImageStyle? headlineImageStyle; - - /// The preferred shape for banner ads, used for in-article banners. - final BannerAdShape? bannerAdShape; + final FeedItemImageStyle? feedItemImageStyle; @override Widget build(BuildContext context) { @@ -76,19 +72,9 @@ class AdmobInlineAdWidget extends StatelessWidget { NativeAdTemplateType.medium => 250, }; } else if (inlineAd is BannerAd) { - // For banner ads, prioritize bannerAdShape if provided (for in-article ads). - // Otherwise, fall back to headlineImageStyle (for feed ads). - if (bannerAdShape != null) { - adHeight = switch (bannerAdShape) { - BannerAdShape.square => 250, - BannerAdShape.rectangle => 50, - _ => 50, - }; - } else { - adHeight = headlineImageStyle == HeadlineImageStyle.largeThumbnail - ? 250 // Assumes large thumbnail feed style wants a medium rectangle banner - : 50; - } + adHeight = feedItemImageStyle == FeedItemImageStyle.largeThumbnail + ? 250 // Assumes large thumbnail feed style wants a medium rectangle banner + : 50; } else { // Fallback height for unknown inline ad types. logger.warning( diff --git a/lib/ads/widgets/demo_banner_ad_widget.dart b/lib/ads/widgets/demo_banner_ad_widget.dart index 65804637..48474c89 100644 --- a/lib/ads/widgets/demo_banner_ad_widget.dart +++ b/lib/ads/widgets/demo_banner_ad_widget.dart @@ -11,36 +11,19 @@ import 'package:ui_kit/ui_kit.dart'; /// {@endtemplate} class DemoBannerAdWidget extends StatelessWidget { /// {@macro demo_banner_ad_widget} - const DemoBannerAdWidget({ - this.headlineImageStyle, - this.bannerAdShape, - super.key, - }); + const DemoBannerAdWidget({this.feedItemImageStyle, super.key}); /// The user's preference for feed layout, used to determine the ad's visual size. - final HeadlineImageStyle? headlineImageStyle; - - /// The preferred shape for banner ads, used for in-article banners. - final BannerAdShape? bannerAdShape; + final FeedItemImageStyle? feedItemImageStyle; @override Widget build(BuildContext context) { final theme = Theme.of(context); - // Determine the height. Prioritize bannerAdShape for in-article context. - // Fall back to headlineImageStyle for feed context. - final int adHeight; - if (bannerAdShape != null) { - adHeight = switch (bannerAdShape) { - BannerAdShape.square => 250, - BannerAdShape.rectangle => 50, - _ => 50, - }; - } else { - adHeight = headlineImageStyle == HeadlineImageStyle.largeThumbnail - ? 250 - : 50; - } + // Determine the height based on the feedItemImageStyle. + final adHeight = feedItemImageStyle == FeedItemImageStyle.largeThumbnail + ? 250 + : 50; return Card( margin: const EdgeInsets.symmetric( diff --git a/lib/ads/widgets/demo_interstitial_ad_dialog.dart b/lib/ads/widgets/demo_interstitial_ad_dialog.dart index 7b8867c5..aaffc4be 100644 --- a/lib/ads/widgets/demo_interstitial_ad_dialog.dart +++ b/lib/ads/widgets/demo_interstitial_ad_dialog.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:ui_kit/ui_kit.dart'; /// {@template demo_interstitial_ad_dialog} @@ -21,7 +21,6 @@ class DemoInterstitialAdDialog extends StatefulWidget { } class _DemoInterstitialAdDialogState extends State { - //TODO(fulleni): make teh countdown configurable throuigh teh remote config. static const int _countdownDuration = 5; int _countdown = _countdownDuration; Timer? _timer; @@ -53,7 +52,7 @@ class _DemoInterstitialAdDialogState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final l10n = AppLocalizations.of(context); + final l10n = AppLocalizationsX(context).l10n; final canClose = _countdown == 0; return Dialog.fullscreen( diff --git a/lib/ads/widgets/demo_native_ad_widget.dart b/lib/ads/widgets/demo_native_ad_widget.dart index a07872c9..43fafa54 100644 --- a/lib/ads/widgets/demo_native_ad_widget.dart +++ b/lib/ads/widgets/demo_native_ad_widget.dart @@ -11,18 +11,18 @@ import 'package:ui_kit/ui_kit.dart'; /// {@endtemplate} class DemoNativeAdWidget extends StatelessWidget { /// {@macro demo_native_ad_widget} - const DemoNativeAdWidget({this.headlineImageStyle, super.key}); + const DemoNativeAdWidget({this.feedItemImageStyle, super.key}); /// The user's preference for feed layout, used to determine the ad's visual size. - final HeadlineImageStyle? headlineImageStyle; + final FeedItemImageStyle? feedItemImageStyle; @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppLocalizations.of(context); - // Determine the height based on the headlineImageStyle, mimicking real ad widgets. - final adHeight = headlineImageStyle == HeadlineImageStyle.largeThumbnail + // Determine the height based on the feedItemImageStyle, mimicking real ad widgets. + final adHeight = feedItemImageStyle == FeedItemImageStyle.largeThumbnail ? 250 : 120; diff --git a/lib/ads/widgets/feed_ad_loader_widget.dart b/lib/ads/widgets/feed_ad_loader_widget.dart index 7f6fc39b..3d30df85 100644 --- a/lib/ads/widgets/feed_ad_loader_widget.dart +++ b/lib/ads/widgets/feed_ad_loader_widget.dart @@ -47,7 +47,7 @@ class FeedAdLoaderWidget extends StatefulWidget { required this.contextKey, required this.adPlaceholder, required this.adThemeStyle, - required this.adConfig, + required this.remoteConfig, super.key, }); @@ -63,7 +63,7 @@ class FeedAdLoaderWidget extends StatefulWidget { final AdThemeStyle adThemeStyle; /// The full remote configuration for ads, used to determine ad loading rules. - final AdConfig adConfig; + final RemoteConfig remoteConfig; @override State createState() => _FeedAdLoaderWidgetState(); @@ -101,7 +101,7 @@ class _FeedAdLoaderWidgetState extends State { // reload. if (widget.contextKey != oldWidget.contextKey || widget.adPlaceholder.id != oldWidget.adPlaceholder.id || - widget.adConfig != oldWidget.adConfig) { + widget.remoteConfig != oldWidget.remoteConfig) { _logger.info( 'FeedAdLoaderWidget updated for new placeholder ID ' '(${widget.adPlaceholder.id}) or contextKey (${widget.contextKey}). ' @@ -247,14 +247,15 @@ class _FeedAdLoaderWidgetState extends State { } final appBlocState = context.read().state; - final headlineImageStyle = appBlocState.headlineImageStyle; + final feedItemImageStyle = + appBlocState.settings!.feedSettings.feedItemImageStyle; final userRole = appBlocState.user?.appRole ?? AppUserRole.guestUser; final loadedAd = await _adService.getFeedAd( - adConfig: widget.adConfig, + adConfig: widget.remoteConfig.features.ads, adType: widget.adPlaceholder.adType, adThemeStyle: widget.adThemeStyle, - headlineImageStyle: headlineImageStyle, + feedItemImageStyle: feedItemImageStyle, userRole: userRole, ); @@ -316,7 +317,12 @@ class _FeedAdLoaderWidgetState extends State { Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); - final headlineImageStyle = context.read().state.headlineImageStyle; + final feedItemImageStyle = context + .read() + .state + .settings! + .feedSettings + .feedItemImageStyle; if (_isLoading || _hasError || _loadedAd == null) { // Show a user-friendly message when loading, on error, or if no ad is @@ -351,15 +357,15 @@ class _FeedAdLoaderWidgetState extends State { case AdPlatformType.admob: return AdmobInlineAdWidget( inlineAd: _loadedAd!, - headlineImageStyle: headlineImageStyle, + feedItemImageStyle: feedItemImageStyle, ); case AdPlatformType.demo: // In demo environment, display placeholder ads directly. switch (widget.adPlaceholder.adType) { case AdType.native: - return DemoNativeAdWidget(headlineImageStyle: headlineImageStyle); + return DemoNativeAdWidget(feedItemImageStyle: feedItemImageStyle); case AdType.banner: - return DemoBannerAdWidget(headlineImageStyle: headlineImageStyle); + return DemoBannerAdWidget(feedItemImageStyle: feedItemImageStyle); case AdType.interstitial: case AdType.video: // Interstitial and video ads are not inline, so they won't be diff --git a/lib/ads/widgets/in_article_ad_loader_widget.dart b/lib/ads/widgets/in_article_ad_loader_widget.dart deleted file mode 100644 index a5c52ee0..00000000 --- a/lib/ads/widgets/in_article_ad_loader_widget.dart +++ /dev/null @@ -1,279 +0,0 @@ -import 'dart:async'; - -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/models/ad_theme_style.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/inline_ad.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/ads/services/ad_service.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/ads/widgets/admob_inline_ad_widget.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/ads/widgets/demo_banner_ad_widget.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/l10n/l10n.dart'; -import 'package:logging/logging.dart'; -import 'package:ui_kit/ui_kit.dart'; - -/// {@template in_article_ad_loader_widget} -/// A self-contained, stateful widget that manages the entire lifecycle -/// of a single in-article ad slot. -/// -/// This widget is designed to be robust and efficient. It fetches an -/// [InlineAd] from the [AdService] in its `initState` method and -/// stores it in its own local state. Crucially, it is responsible -/// for disposing of the loaded ad's resources in its `dispose` method. -/// -/// This approach ensures that the ad's lifecycle is tightly coupled -/// with the widget's lifecycle, preventing ad cache collisions when -/// multiple instances of the same article page are in the navigation -/// stack. It also ensures proper resource cleanup when the widget -/// is removed from the widget tree. -/// {@endtemplate} -class InArticleAdLoaderWidget extends StatefulWidget { - /// {@macro in_article_ad_loader_widget} - const InArticleAdLoaderWidget({ - required this.slotType, - required this.adThemeStyle, - required this.adConfig, - super.key, - }); - - /// The type of the in-article ad slot. - final InArticleAdSlotType slotType; - - /// The current theme style for ads, used during ad loading. - final AdThemeStyle adThemeStyle; - - /// The full remote configuration for ads, used to determine ad loading rules. - final AdConfig adConfig; - - @override - State createState() => - _InArticleAdLoaderWidgetState(); -} - -class _InArticleAdLoaderWidgetState extends State { - /// The currently loaded inline ad object. - /// This is managed entirely by this widget's state. - InlineAd? _loadedAd; - bool _isLoading = true; - bool _hasError = false; - final Logger _logger = Logger('InArticleAdLoaderWidget'); - late final AdService _adService; - - Completer? _loadAdCompleter; - - @override - void initState() { - super.initState(); - // AdService is used to fetch new ads and dispose of them. - _adService = context.read(); - _loadAd(); - } - - @override - void didUpdateWidget(covariant InArticleAdLoaderWidget oldWidget) { - super.didUpdateWidget(oldWidget); - // If the slotType or adConfig changes, it means this widget is - // being reused for a different ad slot or its configuration has - // been updated. We need to cancel any ongoing load for the old ad - // and initiate a new load for the new ad. - // Also, if the adConfig changes, we should re-evaluate and potentially - // reload. - if (widget.slotType != oldWidget.slotType || - widget.adConfig != oldWidget.adConfig) { - _logger.info( - 'InArticleAdLoaderWidget updated for new slot type: ' - '${widget.slotType.name} or adConfig changed. Re-loading ad.', - ); - - // Cancel the previous loading operation if it's still active and not yet - // completed. This prevents a race condition if a new load is triggered. - if (_loadAdCompleter != null && !_loadAdCompleter!.isCompleted) { - // Complete normally to prevent crashes - _loadAdCompleter!.complete(); - } - _loadAdCompleter = null; - - // If an ad was previously loaded, dispose of its resources - // immediately as this widget is now responsible for its lifecycle. - if (_loadedAd != null) { - _logger.info( - 'Disposing old ad for slot "${oldWidget.slotType.name}" ' - 'before loading new one.', - ); - _adService.disposeAd(_loadedAd); - } - - // Immediately set the widget to a loading state to prevent UI flicker. - // This ensures a smooth transition from the old ad (or no ad) to the - // loading indicator for the new ad. - if (mounted) { - setState(() { - _loadedAd = null; - _isLoading = true; - _hasError = false; - }); - } - _loadAd(); - } - } - - @override - void dispose() { - // The ad object (_loadedAd) is managed by this widget. - // Therefore, its resources MUST be explicitly disposed of here - // when the widget is removed from the widget tree to prevent - // memory leaks. - if (_loadedAd != null) { - _logger.info( - 'Disposing in-article ad for slot "${widget.slotType.name}" ' - 'as widget is being disposed.', - ); - _adService.disposeAd(_loadedAd); - } - - // Cancel any pending ad loading operation when the widget is disposed. - // This prevents `setState()` calls on a disposed widget. - if (_loadAdCompleter != null && !_loadAdCompleter!.isCompleted) { - // Complete normally to prevent crashes - _loadAdCompleter!.complete(); - } - _loadAdCompleter = null; - super.dispose(); - } - - /// Loads the in-article ad for this slot. - /// - /// This method directly requests a new in-article ad from the - /// [AdService] using `getInArticleAd`. It stores the loaded ad in - /// its local state (`_loadedAd`). This widget does not use an - /// external cache for its ads; its lifecycle is entirely self-managed. - /// - /// It also includes defensive checks (`mounted`) to prevent `setState` calls - /// on disposed widgets and ensures the `_loadAdCompleter` is always - /// completed to prevent `StateError`s. - Future _loadAd() async { - // Initialize a new completer for this loading operation. - _loadAdCompleter = Completer(); - - // Ensure the widget is still mounted before proceeding. - // This prevents the "setState() called after dispose()" error. - if (!mounted) { - if (_loadAdCompleter?.isCompleted == false) { - _loadAdCompleter!.complete(); - } - return; - } - - _logger.info('Loading new in-article ad for slot: ${widget.slotType.name}'); - try { - // Get the current user role from AppBloc - final appBlocState = context.read().state; - final userRole = appBlocState.user?.appRole ?? AppUserRole.guestUser; - - // Call AdService.getInArticleAd with the full AdConfig. - final loadedAd = await _adService.getInArticleAd( - adConfig: widget.adConfig, - adThemeStyle: widget.adThemeStyle, - userRole: userRole, - slotType: widget.slotType, - ); - - if (loadedAd != null) { - _logger.info( - 'New in-article ad loaded for slot: ${widget.slotType.name}', - ); - if (mounted) { - setState(() { - _loadedAd = loadedAd; - _isLoading = false; - }); - } - if (_loadAdCompleter?.isCompleted == false) { - _loadAdCompleter!.complete(); - } - } else { - _logger.warning( - 'Failed to load in-article ad for slot: ${widget.slotType.name}. ' - 'No ad returned.', - ); - if (mounted) { - setState(() { - _hasError = true; - _isLoading = false; - }); - } - if (_loadAdCompleter?.isCompleted == false) { - _loadAdCompleter!.complete(); - } - } - } catch (e, s) { - _logger.severe( - 'Error loading in-article ad for slot: ${widget.slotType.name}: $e', - e, - s, - ); - if (mounted) { - setState(() { - _hasError = true; - _isLoading = false; - }); - } - if (_loadAdCompleter?.isCompleted == false) { - _loadAdCompleter!.complete(); - } - } - } - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - final theme = Theme.of(context); - final headlineImageStyle = context.read().state.headlineImageStyle; - - if (_isLoading || _hasError || _loadedAd == null) { - // Show a user-friendly message when loading, on error, or if no ad is - // loaded. - return Card( - margin: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingMedium, - vertical: AppSpacing.xs, - ), - child: AspectRatio( - aspectRatio: 16 / 9, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.paddingMedium, - ), - child: Center( - child: Text( - l10n.adInfoPlaceholderText, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ), - ), - ), - ); - } else { - // If an ad is successfully loaded, dispatch to the appropriate - // provider-specific widget for rendering. - switch (_loadedAd!.provider) { - case AdPlatformType.admob: - return AdmobInlineAdWidget( - inlineAd: _loadedAd!, - headlineImageStyle: headlineImageStyle, - bannerAdShape: widget.adConfig.articleAdConfiguration.bannerAdShape, - ); - case AdPlatformType.demo: - // In demo environment, display placeholder ads directly. - // In-article ads are now always banners, so we use DemoBannerAdWidget. - return DemoBannerAdWidget( - bannerAdShape: widget.adConfig.articleAdConfiguration.bannerAdShape, - ); - } - } - } -} diff --git a/lib/ads/widgets/widgets.dart b/lib/ads/widgets/widgets.dart index 38fdb27a..4c8a0018 100644 --- a/lib/ads/widgets/widgets.dart +++ b/lib/ads/widgets/widgets.dart @@ -3,4 +3,3 @@ export 'demo_banner_ad_widget.dart'; export 'demo_interstitial_ad_dialog.dart'; export 'demo_native_ad_widget.dart'; export 'feed_ad_loader_widget.dart'; -export 'in_article_ad_loader_widget.dart'; diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index a262dbd0..444a7f0c 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -35,12 +35,12 @@ class AppBloc extends Bloc { AppBloc({ required User? user, required RemoteConfig remoteConfig, - required UserAppSettings? settings, + required AppSettings? settings, required UserContentPreferences? userContentPreferences, required DataRepository remoteConfigRepository, required AppInitializer appInitializer, required AuthRepository authRepository, - required DataRepository userAppSettingsRepository, + required DataRepository appSettingsRepository, required DataRepository userContentPreferencesRepository, required InlineAdCacheService inlineAdCacheService, @@ -51,7 +51,7 @@ class AppBloc extends Bloc { }) : _remoteConfigRepository = remoteConfigRepository, _appInitializer = appInitializer, _authRepository = authRepository, - _userAppSettingsRepository = userAppSettingsRepository, + _appSettingsRepository = appSettingsRepository, _userContentPreferencesRepository = userContentPreferencesRepository, _userRepository = userRepository, _inAppNotificationRepository = inAppNotificationRepository, @@ -74,7 +74,7 @@ class AppBloc extends Bloc { // Register event handlers for various app-level events. on(_onAppStarted); on(_onAppUserChanged); - on(_onUserAppSettingsRefreshed); + on(_onUserAppSettingsRefreshed); on(_onUserContentPreferencesRefreshed); on(_onAppSettingsChanged); on(_onAppPeriodicConfigFetchRequested); @@ -94,6 +94,7 @@ class AppBloc extends Bloc { _onAllInAppNotificationsMarkedAsRead, ); on(_onInAppNotificationMarkedAsRead); + on(_onAppNotificationTapped); // Listen to token refresh events from the push notification service. // When a token is refreshed, dispatch an event to trigger device @@ -116,7 +117,7 @@ class AppBloc extends Bloc { final DataRepository _remoteConfigRepository; final AppInitializer _appInitializer; final AuthRepository _authRepository; - final DataRepository _userAppSettingsRepository; + final DataRepository _appSettingsRepository; final DataRepository _userContentPreferencesRepository; final DataRepository _userRepository; @@ -137,6 +138,24 @@ class AppBloc extends Bloc { // If a user is already logged in when the app starts, register their // device for push notifications. if (state.user != null) { + // Check for existing unread notifications on startup. + // This ensures the notification dot is shown correctly if the user + // has unread notifications from a previous session. + try { + final unreadCount = await _inAppNotificationRepository.count( + userId: state.user!.id, + filter: {'readAt': null}, + ); + if (unreadCount > 0) { + emit(state.copyWith(hasUnreadInAppNotifications: true)); + } + } catch (e, s) { + _logger.severe( + 'Failed to check for unread notifications on app start.', + e, + s, + ); + } await _registerDeviceForPushNotifications(state.user!.id); } } @@ -262,17 +281,15 @@ class AppBloc extends Bloc { /// Handles refreshing/loading app settings (theme, font). Future _onUserAppSettingsRefreshed( - AppUserAppSettingsRefreshed event, + AppSettingsRefreshed event, Emitter emit, ) async { if (state.user == null) { - _logger.info( - '[AppBloc] Skipping AppUserAppSettingsRefreshed: User is null.', - ); + _logger.info('[AppBloc] Skipping AppSettingsRefreshed: User is null.'); return; } - final settings = await _userAppSettingsRepository.read( + final settings = await _appSettingsRepository.read( id: state.user!.id, userId: state.user!.id, ); @@ -306,7 +323,7 @@ class AppBloc extends Bloc { ) async { if (state.user == null || state.settings == null) { _logger.warning( - '[AppBloc] Skipping AppSettingsChanged: User or UserAppSettings not loaded.', + '[AppBloc] Skipping AppSettingsChanged: User or AppSettings not loaded.', ); return; } @@ -319,17 +336,17 @@ class AppBloc extends Bloc { emit(state.copyWith(settings: updatedSettings)); try { - await _userAppSettingsRepository.update( + await _appSettingsRepository.update( id: updatedSettings.id, item: updatedSettings, userId: updatedSettings.id, ); _logger.info( - '[AppBloc] UserAppSettings successfully updated for user ${updatedSettings.id}.', + '[AppBloc] AppSettings successfully updated for user ${updatedSettings.id}.', ); } catch (e, s) { _logger.severe( - 'Failed to persist UserAppSettings for user ${updatedSettings.id}.', + 'Failed to persist AppSettings for user ${updatedSettings.id}.', e, s, ); @@ -355,7 +372,7 @@ class AppBloc extends Bloc { id: kRemoteConfigId, ); - if (remoteConfig.appStatus.isUnderMaintenance) { + if (remoteConfig.app.maintenance.isUnderMaintenance) { _logger.warning( '[AppBloc] Maintenance mode detected. Updating status.', ); @@ -369,7 +386,7 @@ class AppBloc extends Bloc { } if (state.status == AppLifeCycleStatus.underMaintenance && - !remoteConfig.appStatus.isUnderMaintenance) { + !remoteConfig.app.maintenance.isUnderMaintenance) { _logger.info( '[AppBloc] Maintenance mode lifted. Restoring previous status.', ); @@ -683,6 +700,44 @@ class AppBloc extends Bloc { } } + /// Handles marking a specific notification as read when it's tapped. + Future _onAppNotificationTapped( + AppNotificationTapped event, + Emitter emit, + ) async { + final userId = state.user?.id; + if (userId == null) { + _logger.warning( + '[AppBloc] Cannot mark notification as read: user is not logged in.', + ); + return; + } + + try { + // First, read the existing notification to get the full object. + final notification = await _inAppNotificationRepository.read( + id: event.notificationId, + userId: userId, + ); + + // If already read, do nothing. + if (notification.isRead) return; + + // Then, update it with the 'readAt' timestamp. + await _inAppNotificationRepository.update( + id: notification.id, + item: notification.copyWith(readAt: DateTime.now()), + userId: userId, + ); + + _logger.info( + '[AppBloc] Marked notification ${event.notificationId} as read.', + ); + } catch (e, s) { + _logger.severe('Failed to mark notification as read.', e, s); + } + } + /// Handles the [AppPushNotificationTokenRefreshed] event. /// /// This event is triggered when the underlying push notification provider diff --git a/lib/app/bloc/app_event.dart b/lib/app/bloc/app_event.dart index 70f3eb28..131a0fb2 100644 --- a/lib/app/bloc/app_event.dart +++ b/lib/app/bloc/app_event.dart @@ -37,8 +37,8 @@ class AppUserChanged extends AppEvent { /// /// This event is typically used when external changes might have occurred /// or when a manual refresh of settings is desired. -class AppUserAppSettingsRefreshed extends AppEvent { - const AppUserAppSettingsRefreshed(); +class AppSettingsRefreshed extends AppEvent { + const AppSettingsRefreshed(); } /// Dispatched to request a refresh of the user's content preferences. @@ -51,13 +51,13 @@ class AppUserContentPreferencesRefreshed extends AppEvent { /// Dispatched when the user's application settings have been updated. /// -/// This event carries the complete, updated [UserAppSettings] object, +/// This event carries the complete, updated [AppSettings] object, /// allowing the [AppBloc] to update its state and persist the changes. class AppSettingsChanged extends AppEvent { const AppSettingsChanged(this.settings); - /// The updated [UserAppSettings] object. - final UserAppSettings settings; + /// The updated [AppSettings] object. + final AppSettings settings; @override List get props => [settings]; @@ -235,3 +235,18 @@ class AppAllInAppNotificationsMarkedAsRead extends AppEvent { /// {@macro app_all_in_app_notifications_marked_as_read} const AppAllInAppNotificationsMarkedAsRead(); } + +/// {@template app_notification_tapped} +/// Dispatched when a push notification is tapped by the user from the system +/// tray, signaling that it should be marked as read. +/// {@endtemplate} +class AppNotificationTapped extends AppEvent { + /// {@macro app_notification_tapped} + const AppNotificationTapped(this.notificationId); + + /// The unique ID of the notification that was tapped. + final String notificationId; + + @override + List get props => [notificationId]; +} diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index 557f2368..202d7b87 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -31,7 +31,7 @@ class AppState extends Equatable { /// The user's application settings, including display preferences and language. /// This is null until successfully fetched from the backend. - final UserAppSettings? settings; + final AppSettings? settings; /// The remote configuration fetched from the backend. /// Contains global settings like maintenance mode, update requirements, and ad configurations. @@ -103,11 +103,11 @@ class AppState extends Equatable { return settings?.displaySettings.fontWeight ?? AppFontWeight.regular; } - /// The current headline image style, derived from [settings]. - /// Defaults to [HeadlineImageStyle.smallThumbnail] if [settings] are not yet loaded. - HeadlineImageStyle get headlineImageStyle { - return settings?.feedPreferences.headlineImageStyle ?? - HeadlineImageStyle.smallThumbnail; + /// The current feed item image style, derived from [settings]. + /// Defaults to [FeedItemImageStyle.smallThumbnail] if [settings] are not yet loaded. + FeedItemImageStyle get feedItemImageStyle { + return settings?.feedSettings.feedItemImageStyle ?? + FeedItemImageStyle.smallThumbnail; } /// The currently selected locale for localization, derived from [settings]. @@ -135,7 +135,7 @@ class AppState extends Equatable { AppState copyWith({ AppLifeCycleStatus? status, User? user, - UserAppSettings? settings, + AppSettings? settings, RemoteConfig? remoteConfig, HttpException? error, bool clearError = false, diff --git a/lib/app/config/app_config.dart b/lib/app/config/app_config.dart index 23534e0c..82a1b172 100644 --- a/lib/app/config/app_config.dart +++ b/lib/app/config/app_config.dart @@ -105,7 +105,7 @@ class AppConfig { // formatted dummies required to satisfy Firebase initialization. factory AppConfig.demo() => AppConfig( environment: AppEnvironment.demo, - baseUrl: '', // No API access needed for in-memory demo + baseUrl: '', // Placeholders for demo oneSignalAndroidAppId: 'YOUR_DEMO_ONESIGNAL_ANDROID_APP_ID', oneSignalIosAppId: 'YOUR_DEMO_ONESIGNAL_IOS_APP_ID', diff --git a/lib/app/models/initialization_result.dart b/lib/app/models/initialization_result.dart index 78b7b052..ee12951a 100644 --- a/lib/app/models/initialization_result.dart +++ b/lib/app/models/initialization_result.dart @@ -38,7 +38,7 @@ final class InitializationSuccess extends InitializationResult { /// The user's specific application settings (theme, font, etc.). /// Null if the user is unauthenticated. - final UserAppSettings? settings; + final AppSettings? settings; /// The user's specific content preferences (followed items, saved articles). /// Null if the user is unauthenticated. diff --git a/lib/app/services/app_initializer.dart b/lib/app/services/app_initializer.dart index 6a716145..e0d0b600 100644 --- a/lib/app/services/app_initializer.dart +++ b/lib/app/services/app_initializer.dart @@ -25,7 +25,7 @@ import 'package:pub_semver/pub_semver.dart'; /// 1. Fetch `RemoteConfig`. /// 2. Check for blocking states (maintenance, forced update). /// 3. Fetch the initial `User`. -/// 4. If a user exists, fetch their `UserAppSettings` and +/// 4. If a user exists, fetch their `AppSettings` and /// `UserContentPreferences` in parallel. /// 5. Handle demo-specific data initialization. /// 6. Return a single, immutable `InitializationResult` (either `Success` or @@ -40,7 +40,7 @@ class AppInitializer { /// Requires all repositories and services needed for the startup sequence. AppInitializer({ required AuthRepository authenticationRepository, - required DataRepository userAppSettingsRepository, + required DataRepository appSettingsRepository, required DataRepository userContentPreferencesRepository, required DataRepository remoteConfigRepository, @@ -50,7 +50,7 @@ class AppInitializer { this.demoDataMigrationService, this.demoDataInitializerService, }) : _authenticationRepository = authenticationRepository, - _userAppSettingsRepository = userAppSettingsRepository, + _appSettingsRepository = appSettingsRepository, _userContentPreferencesRepository = userContentPreferencesRepository, _remoteConfigRepository = remoteConfigRepository, _environment = environment, @@ -58,7 +58,7 @@ class AppInitializer { _logger = logger; final AuthRepository _authenticationRepository; - final DataRepository _userAppSettingsRepository; + final DataRepository _appSettingsRepository; final DataRepository _userContentPreferencesRepository; final DataRepository _remoteConfigRepository; @@ -102,7 +102,7 @@ class AppInitializer { // --- Gate 2: Check for Maintenance Mode --- // If maintenance mode is enabled, halt the entire startup process. - if (remoteConfig.appStatus.isUnderMaintenance) { + if (remoteConfig.app.maintenance.isUnderMaintenance) { _logger.warning('[AppInitializer] App is under maintenance. Halting.'); return const InitializationFailure( status: AppLifeCycleStatus.underMaintenance, @@ -111,7 +111,7 @@ class AppInitializer { // --- Gate 3: Check for Forced Update --- // If a forced update is required, halt the startup process. - if (remoteConfig.appStatus.isLatestVersionOnly) { + if (remoteConfig.app.update.isLatestVersionOnly) { _logger.fine('[AppInitializer] Version check required.'); final currentVersionString = await _packageInfoService.getAppVersion(); if (currentVersionString == null) { @@ -123,7 +123,7 @@ class AppInitializer { try { final currentVersion = Version.parse(currentVersionString); final latestRequiredVersion = Version.parse( - remoteConfig.appStatus.latestAppVersion, + remoteConfig.app.update.latestAppVersion, ); if (currentVersion < latestRequiredVersion) { _logger.warning( @@ -133,7 +133,7 @@ class AppInitializer { return InitializationFailure( status: AppLifeCycleStatus.updateRequired, currentAppVersion: currentVersionString, - latestAppVersion: remoteConfig.appStatus.latestAppVersion, + latestAppVersion: remoteConfig.app.update.latestAppVersion, ); } _logger.fine( @@ -180,16 +180,16 @@ class AppInitializer { try { // Fetch settings and preferences concurrently for performance. var [ - userAppSettings as UserAppSettings?, + appSettings as AppSettings?, userContentPreferences as UserContentPreferences?, ] = await Future.wait([ - _userAppSettingsRepository.read(id: user.id, userId: user.id), + _appSettingsRepository.read(id: user.id, userId: user.id), _userContentPreferencesRepository.read(id: user.id, userId: user.id), ]); _logger.fine( '[AppInitializer] Parallel fetch complete. ' - 'Settings: ${userAppSettings != null}, ' + 'Settings: ${appSettings != null}, ' 'Preferences: ${userContentPreferences != null}', ); @@ -197,7 +197,7 @@ class AppInitializer { // If in demo mode and the user data is missing (e.g., first sign-in), // create it from fixtures. if (_environment == local_config.AppEnvironment.demo && - (userAppSettings == null || userContentPreferences == null)) { + (appSettings == null || userContentPreferences == null)) { _logger.info( '[AppInitializer] Demo mode: User data missing. ' 'Initializing from fixtures for user ${user.id}.', @@ -206,8 +206,8 @@ class AppInitializer { // Re-fetch the data after initialization. _logger.fine('[AppInitializer] Re-fetching data after demo init...'); - [userAppSettings, userContentPreferences] = await Future.wait([ - _userAppSettingsRepository.read(id: user.id, userId: user.id), + [appSettings, userContentPreferences] = await Future.wait([ + _appSettingsRepository.read(id: user.id, userId: user.id), _userContentPreferencesRepository.read(id: user.id, userId: user.id), ]); } @@ -218,7 +218,7 @@ class AppInitializer { return InitializationSuccess( remoteConfig: remoteConfig, user: user, - settings: userAppSettings, + settings: appSettings, userContentPreferences: userContentPreferences, ); } on HttpException catch (e, s) { @@ -250,7 +250,7 @@ class AppInitializer { /// articles) from the old anonymous user ID to the new authenticated /// user ID. /// 2. **Re-fetching All User Data:** After any potential migration, it - /// re-fetches all user-specific data (`UserAppSettings`, + /// re-fetches all user-specific data (`AppSettings`, /// `UserContentPreferences`) for the `newUser`. This is crucial to /// ensure the app's state is fresh and not polluted with data from the /// previous user. @@ -325,10 +325,10 @@ class AppInitializer { try { final [ - userAppSettings as UserAppSettings?, + appSettings as AppSettings?, userContentPreferences as UserContentPreferences?, ] = await Future.wait([ - _userAppSettingsRepository.read(id: newUser.id, userId: newUser.id), + _appSettingsRepository.read(id: newUser.id, userId: newUser.id), _userContentPreferencesRepository.read( id: newUser.id, userId: newUser.id, @@ -339,7 +339,7 @@ class AppInitializer { return InitializationSuccess( remoteConfig: remoteConfig, user: newUser, - settings: userAppSettings, + settings: appSettings, userContentPreferences: userContentPreferences, ); } on HttpException catch (e, s) { diff --git a/lib/app/services/demo_data_initializer_service.dart b/lib/app/services/demo_data_initializer_service.dart index 7896f985..3e75d26c 100644 --- a/lib/app/services/demo_data_initializer_service.dart +++ b/lib/app/services/demo_data_initializer_service.dart @@ -4,7 +4,7 @@ import 'package:logging/logging.dart'; /// {@template demo_data_initializer_service} /// A service responsible for ensuring that essential user-specific data -/// (like [UserAppSettings] and [UserContentPreferences]) exists for a new user +/// (like [AppSettings] and [UserContentPreferences]) exists for a new user /// in the demo environment. /// /// Instead of creating default empty objects, this service now acts as a @@ -19,28 +19,28 @@ import 'package:logging/logging.dart'; class DemoDataInitializerService { /// {@macro demo_data_initializer_service} DemoDataInitializerService({ - required DataRepository userAppSettingsRepository, + required DataRepository appSettingsRepository, required DataRepository userContentPreferencesRepository, required DataRepository inAppNotificationRepository, - required this.userAppSettingsFixturesData, + required this.appSettingsFixturesData, required this.userContentPreferencesFixturesData, required this.inAppNotificationsFixturesData, - }) : _userAppSettingsRepository = userAppSettingsRepository, + }) : _appSettingsRepository = appSettingsRepository, _userContentPreferencesRepository = userContentPreferencesRepository, _inAppNotificationRepository = inAppNotificationRepository, _logger = Logger('DemoDataInitializerService'); - final DataRepository _userAppSettingsRepository; + final DataRepository _appSettingsRepository; final DataRepository _userContentPreferencesRepository; final DataRepository _inAppNotificationRepository; final Logger _logger; - /// A list of [UserAppSettings] fixture data to be used as a template. + /// A list of [AppSettings] fixture data to be used as a template. /// /// The first item in this list will be cloned for new users. - final List userAppSettingsFixturesData; + final List appSettingsFixturesData; /// A list of [UserContentPreferences] fixture data to be used as a template. /// @@ -55,7 +55,7 @@ class DemoDataInitializerService { /// Initializes essential user-specific data in the in-memory clients /// for the given [user]. /// - /// This method checks if [UserAppSettings] and [UserContentPreferences] + /// This method checks if [AppSettings] and [UserContentPreferences] /// exist for the provided user ID. If any are missing, it creates them /// with default values. /// @@ -66,7 +66,7 @@ class DemoDataInitializerService { _logger.info('Initializing user-specific data for user ID: ${user.id}'); await Future.wait([ - _ensureUserAppSettingsExist(user.id), + _ensureAppSettingsExist(user.id), _ensureUserContentPreferencesExist(user.id), _ensureInAppNotificationsExist(user.id), ]); @@ -76,39 +76,39 @@ class DemoDataInitializerService { ); } - /// Ensures that [UserAppSettings] exist for the given [userId]. + /// Ensures that [AppSettings] exist for the given [userId]. /// If not found, creates default settings. - Future _ensureUserAppSettingsExist(String userId) async { + Future _ensureAppSettingsExist(String userId) async { try { - await _userAppSettingsRepository.read(id: userId, userId: userId); - _logger.info('UserAppSettings found for user ID: $userId.'); + await _appSettingsRepository.read(id: userId, userId: userId); + _logger.info('AppSettings found for user ID: $userId.'); } on NotFoundException { _logger.info( - 'UserAppSettings not found for user ID: ' + 'AppSettings not found for user ID: ' '$userId. Creating settings from fixture.', ); // Clone the first item from the fixture data, assigning the new user's ID. // This ensures every new demo user gets a rich, pre-populated set of settings. - if (userAppSettingsFixturesData.isEmpty) { + if (appSettingsFixturesData.isEmpty) { throw StateError( - 'Cannot create settings from fixture: userAppSettingsFixturesData is empty.', + 'Cannot create settings from fixture: appSettingsFixturesData is empty.', ); } - final fixtureSettings = userAppSettingsFixturesData.first.copyWith( + final fixtureSettings = appSettingsFixturesData.first.copyWith( id: userId, ); - await _userAppSettingsRepository.create( + await _appSettingsRepository.create( item: fixtureSettings, userId: userId, ); _logger.info( - 'UserAppSettings from fixture created for ' + 'AppSettings from fixture created for ' 'user ID: $userId.', ); } catch (e, s) { _logger.severe( - 'Error ensuring UserAppSettings exist ' + 'Error ensuring AppSettings exist ' 'for user ID: $userId: $e', e, s, diff --git a/lib/app/services/demo_data_migration_service.dart b/lib/app/services/demo_data_migration_service.dart index ac9b62ef..da14130e 100644 --- a/lib/app/services/demo_data_migration_service.dart +++ b/lib/app/services/demo_data_migration_service.dart @@ -13,14 +13,14 @@ import 'package:logging/logging.dart'; class DemoDataMigrationService { /// {@macro demo_data_migration_service} DemoDataMigrationService({ - required DataRepository userAppSettingsRepository, + required DataRepository appSettingsRepository, required DataRepository userContentPreferencesRepository, - }) : _userAppSettingsRepository = userAppSettingsRepository, + }) : _appSettingsRepository = appSettingsRepository, _userContentPreferencesRepository = userContentPreferencesRepository, _logger = Logger('DemoDataMigrationService'); - final DataRepository _userAppSettingsRepository; + final DataRepository _appSettingsRepository; final DataRepository _userContentPreferencesRepository; final Logger _logger; @@ -39,9 +39,9 @@ class DemoDataMigrationService { 'anonymous user ID: $oldUserId to authenticated user ID: $newUserId', ); - // Migrate UserAppSettings + // Migrate AppSettings try { - final oldSettings = await _userAppSettingsRepository.read( + final oldSettings = await _appSettingsRepository.read( id: oldUserId, userId: oldUserId, ); @@ -49,7 +49,7 @@ class DemoDataMigrationService { try { // Attempt to update first (if a default entry already exists) - await _userAppSettingsRepository.update( + await _appSettingsRepository.update( id: newUserId, item: newSettings, userId: newUserId, @@ -57,14 +57,14 @@ class DemoDataMigrationService { } on NotFoundException { // If update fails because item not found, try to create try { - await _userAppSettingsRepository.create( + await _appSettingsRepository.create( item: newSettings, userId: newUserId, ); } on ConflictException { // If create fails due to conflict (item was created concurrently), // re-attempt update. This handles a race condition. - await _userAppSettingsRepository.update( + await _appSettingsRepository.update( id: newUserId, item: newSettings, userId: newUserId, @@ -72,19 +72,19 @@ class DemoDataMigrationService { } } - await _userAppSettingsRepository.delete(id: oldUserId, userId: oldUserId); + await _appSettingsRepository.delete(id: oldUserId, userId: oldUserId); _logger.info( - '[DemoDataMigrationService] UserAppSettings migrated successfully ' + '[DemoDataMigrationService] AppSettings migrated successfully ' 'from $oldUserId to $newUserId.', ); } on NotFoundException { _logger.info( - '[DemoDataMigrationService] No UserAppSettings found for old user ID: ' + '[DemoDataMigrationService] No AppSettings found for old user ID: ' '$oldUserId. Skipping migration for settings.', ); } catch (e, s) { _logger.severe( - '[DemoDataMigrationService] Error migrating UserAppSettings from ' + '[DemoDataMigrationService] Error migrating AppSettings from ' '$oldUserId to $newUserId: $e', e, s, diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index c393ad0b..70ec5257 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:auth_repository/auth_repository.dart'; -import 'package:core/core.dart' hide AppStatus; +import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -18,8 +18,8 @@ import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/s import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/notifications/services/push_notification_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/router.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/headline_tap_handler.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/status/view/view.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; @@ -48,7 +48,7 @@ class App extends StatelessWidget { required DataRepository sourcesRepository, required DataRepository userRepository, required DataRepository remoteConfigRepository, - required DataRepository userAppSettingsRepository, + required DataRepository appSettingsRepository, required DataRepository userContentPreferencesRepository, required AppEnvironment environment, @@ -67,7 +67,7 @@ class App extends StatelessWidget { _sourcesRepository = sourcesRepository, _userRepository = userRepository, _remoteConfigRepository = remoteConfigRepository, - _userAppSettingsRepository = userAppSettingsRepository, + _appSettingsRepository = appSettingsRepository, _userContentPreferencesRepository = userContentPreferencesRepository, _pushNotificationService = pushNotificationService, _inAppNotificationRepository = inAppNotificationRepository, @@ -85,7 +85,7 @@ class App extends StatelessWidget { final RemoteConfig remoteConfig; /// The user's settings, pre-fetched during startup. - final UserAppSettings? settings; + final AppSettings? settings; /// The user's content preferences, pre-fetched during startup. final UserContentPreferences? userContentPreferences; @@ -97,7 +97,7 @@ class App extends StatelessWidget { final DataRepository _sourcesRepository; final DataRepository _userRepository; final DataRepository _remoteConfigRepository; - final DataRepository _userAppSettingsRepository; + final DataRepository _appSettingsRepository; final DataRepository _userContentPreferencesRepository; final AppEnvironment _environment; @@ -125,7 +125,7 @@ class App extends StatelessWidget { RepositoryProvider.value(value: _feedDecoratorService), RepositoryProvider.value(value: _userRepository), RepositoryProvider.value(value: _remoteConfigRepository), - RepositoryProvider.value(value: _userAppSettingsRepository), + RepositoryProvider.value(value: _appSettingsRepository), RepositoryProvider.value(value: _userContentPreferencesRepository), RepositoryProvider.value(value: _pushNotificationService), RepositoryProvider.value(value: _inAppNotificationRepository), @@ -147,7 +147,7 @@ class App extends StatelessWidget { remoteConfigRepository: _remoteConfigRepository, appInitializer: context.read(), authRepository: context.read(), - userAppSettingsRepository: _userAppSettingsRepository, + appSettingsRepository: _appSettingsRepository, userContentPreferencesRepository: _userContentPreferencesRepository, logger: context.read(), @@ -201,41 +201,42 @@ class _AppViewState extends State<_AppView> { // Subscribe to the authentication repository's authStateChanges stream. // This stream is the single source of truth for the user's auth state // and drives the entire app lifecycle by dispatching AppUserChanged events. - _userSubscription = context.read().authStateChanges.listen( - (user) => context.read().add(AppUserChanged(user)), - ); + _userSubscription = context.read().authStateChanges.listen(( + user, + ) { + if (mounted) { + context.read().add(AppUserChanged(user)); + } + }); // Subscribe to foreground push notifications. When a message is received, // dispatch an event to the AppBloc to update the UI state (e.g., show an // indicator dot). - _onMessageSubscription = pushNotificationService.onMessage.listen( - (_) => context.read().add(const AppInAppNotificationReceived()), - ); + _onMessageSubscription = pushNotificationService.onMessage.listen((_) { + if (mounted) { + context.read().add(const AppInAppNotificationReceived()); + } + }); // Subscribe to notifications that are tapped and open the app. // This is the core of the deep-linking functionality. _onMessageOpenedAppSubscription = pushNotificationService.onMessageOpenedApp - .listen((payload) { - _routerLogger.fine( - 'Notification opened app with payload: ${payload.data}', - ); - final contentType = - payload.data['contentType'] as String?; // e.g., 'headline' - final id = payload.data['headlineId'] as String?; - final notificationId = payload.data['notificationId'] as String?; + .listen((payload) async { + _routerLogger.fine('Notification opened app with payload: $payload'); + final contentType = payload.contentType; + final contentId = payload.contentId; + final notificationId = payload.notificationId; - if (contentType == 'headline' && id != null) { - // Use pushNamed instead of goNamed. - // goNamed replaces the entire navigation stack, which causes issues - // when the app is launched from a terminated state. The new page - // would lack the necessary ancestor widgets (like RepositoryProviders). - // pushNamed correctly pushes the details page on top of the existing - // stack (e.g., the feed), ensuring a valid context. - _router.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': id}, - extra: {'notificationId': notificationId}, - ); + if (contentType == ContentType.headline && contentId.isNotEmpty) { + // Guard against using BuildContext across async gaps by checking + // if the widget is still mounted before using its context. + if (mounted) { + await HeadlineTapHandler.handleTapFromSystemNotification( + context, + contentId, + notificationId: notificationId, + ); + } } }); // Instantiate and initialize the AppStatusService. @@ -355,9 +356,9 @@ class _AppViewState extends State<_AppView> { supportedLocales: AppLocalizations.supportedLocales, locale: state.locale, home: UpdateRequiredPage( - iosUpdateUrl: state.remoteConfig?.appStatus.iosUpdateUrl, + iosUpdateUrl: state.remoteConfig?.app.update.iosUpdateUrl, androidUpdateUrl: - state.remoteConfig?.appStatus.androidUpdateUrl, + state.remoteConfig?.app.update.androidUpdateUrl, currentAppVersion: state.currentAppVersion, latestRequiredVersion: state.latestAppVersion, ), diff --git a/lib/app/view/app_initialization_page.dart b/lib/app/view/app_initialization_page.dart index 267dc245..ce1474dd 100644 --- a/lib/app/view/app_initialization_page.dart +++ b/lib/app/view/app_initialization_page.dart @@ -48,7 +48,7 @@ class AppInitializationPage extends StatelessWidget { required this.countriesRepository, required this.sourcesRepository, required this.remoteConfigRepository, - required this.userAppSettingsRepository, + required this.appSettingsRepository, required this.userContentPreferencesRepository, required this.environment, required this.adService, @@ -68,7 +68,7 @@ class AppInitializationPage extends StatelessWidget { final DataRepository sourcesRepository; final DataRepository userRepository; final DataRepository remoteConfigRepository; - final DataRepository userAppSettingsRepository; + final DataRepository appSettingsRepository; final DataRepository userContentPreferencesRepository; final AppEnvironment environment; final AdService adService; @@ -109,7 +109,7 @@ class AppInitializationPage extends StatelessWidget { countriesRepository: countriesRepository, sourcesRepository: sourcesRepository, remoteConfigRepository: remoteConfigRepository, - userAppSettingsRepository: userAppSettingsRepository, + appSettingsRepository: appSettingsRepository, userContentPreferencesRepository: userContentPreferencesRepository, environment: environment, diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 2e462ee6..f73fb1bf 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -67,7 +67,6 @@ Future bootstrap( ..config('--- Starting Bootstrap Process ---') ..config('App Environment: $environment'); - WidgetsFlutterBinding.ensureInitialized(); Bloc.observer = const AppBlocObserver(); timeago.setLocaleMessages('en', EnTimeagoMessages()); timeago.setLocaleMessages('ar', ArTimeagoMessages()); @@ -213,7 +212,7 @@ Future bootstrap( late final DataClient countriesClient; late final DataClient sourcesClient; late final DataClient userContentPreferencesClient; - late final DataClient userAppSettingsClient; + late final DataClient appSettingsClient; late final DataClient userClient; late final DataClient inAppNotificationClient; late final DataClient pushNotificationDeviceClient; @@ -276,7 +275,7 @@ Future bootstrap( getId: (i) => i.id, logger: logger, ); - userAppSettingsClient = DataInMemory( + appSettingsClient = DataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, logger: logger, @@ -296,74 +295,8 @@ Future bootstrap( getId: (i) => i.id, logger: logger, ); - } else if (appConfig.environment == app_config.AppEnvironment.development) { - logger.fine('Using API clients for all data repositories (Development).'); - headlinesClient = DataApi( - httpClient: httpClient, - modelName: 'headline', - fromJson: Headline.fromJson, - toJson: (headline) => headline.toJson(), - logger: logger, - ); - topicsClient = DataApi( - httpClient: httpClient, - modelName: 'topic', - fromJson: Topic.fromJson, - toJson: (topic) => topic.toJson(), - logger: logger, - ); - countriesClient = DataApi( - httpClient: httpClient, - modelName: 'country', - fromJson: Country.fromJson, - toJson: (country) => country.toJson(), - logger: logger, - ); - sourcesClient = DataApi( - httpClient: httpClient, - modelName: 'source', - fromJson: Source.fromJson, - toJson: (source) => source.toJson(), - logger: logger, - ); - userContentPreferencesClient = DataApi( - httpClient: httpClient, - modelName: 'user_content_preferences', - fromJson: UserContentPreferences.fromJson, - toJson: (prefs) => prefs.toJson(), - logger: logger, - ); - userAppSettingsClient = DataApi( - httpClient: httpClient, - modelName: 'user_app_settings', - fromJson: UserAppSettings.fromJson, - toJson: (settings) => settings.toJson(), - logger: logger, - ); - userClient = DataApi( - httpClient: httpClient, - modelName: 'user', - fromJson: User.fromJson, - toJson: (user) => user.toJson(), - logger: logger, - ); - inAppNotificationClient = DataApi( - httpClient: httpClient, - modelName: 'in_app_notification', - fromJson: InAppNotification.fromJson, - toJson: (notification) => notification.toJson(), - logger: logger, - ); - pushNotificationDeviceClient = DataApi( - httpClient: httpClient, - modelName: 'push_notification_device', - fromJson: PushNotificationDevice.fromJson, - toJson: (device) => device.toJson(), - logger: logger, - ); } else { - logger.fine('Using API clients for all data repositories (Production).'); - // Default to API clients for production + logger.fine('Using API clients for all data repositories.'); headlinesClient = DataApi( httpClient: httpClient, modelName: 'headline', @@ -399,10 +332,10 @@ Future bootstrap( toJson: (prefs) => prefs.toJson(), logger: logger, ); - userAppSettingsClient = DataApi( + appSettingsClient = DataApi( httpClient: httpClient, - modelName: 'user_app_settings', - fromJson: UserAppSettings.fromJson, + modelName: 'app_settings', + fromJson: AppSettings.fromJson, toJson: (settings) => settings.toJson(), logger: logger, ); @@ -442,8 +375,8 @@ Future bootstrap( DataRepository( dataClient: userContentPreferencesClient, ); - final userAppSettingsRepository = DataRepository( - dataClient: userAppSettingsClient, + final appSettingsRepository = DataRepository( + dataClient: appSettingsClient, ); final userRepository = DataRepository(dataClient: userClient); final inAppNotificationRepository = DataRepository( @@ -463,7 +396,7 @@ Future bootstrap( // Fetch the latest config directly. Since this is post-initialization, // we assume it's available. final remoteConfig = await remoteConfigRepository.read(id: kRemoteConfigId); - final pushNotificationConfig = remoteConfig.pushNotificationConfig; + final pushNotificationConfig = remoteConfig.features.pushNotifications; // In the demo environment, always use the NoOpPushNotificationService. // This service is enhanced to simulate a successful permission flow, @@ -510,7 +443,7 @@ Future bootstrap( final demoDataMigrationService = appConfig.environment == app_config.AppEnvironment.demo ? DemoDataMigrationService( - userAppSettingsRepository: userAppSettingsRepository, + appSettingsRepository: appSettingsRepository, userContentPreferencesRepository: userContentPreferencesRepository, ) : null; @@ -526,10 +459,10 @@ Future bootstrap( final demoDataInitializerService = appConfig.environment == app_config.AppEnvironment.demo ? DemoDataInitializerService( - userAppSettingsRepository: userAppSettingsRepository, + appSettingsRepository: appSettingsRepository, userContentPreferencesRepository: userContentPreferencesRepository, inAppNotificationRepository: inAppNotificationRepository, - userAppSettingsFixturesData: userAppSettingsFixturesData, + appSettingsFixturesData: appSettingsFixturesData, userContentPreferencesFixturesData: userContentPreferencesFixturesData, inAppNotificationsFixturesData: inAppNotificationsFixturesData, @@ -542,7 +475,7 @@ Future bootstrap( ..info('9. Initializing AppInitializer service...'); final appInitializer = AppInitializer( authenticationRepository: authenticationRepository, - userAppSettingsRepository: userAppSettingsRepository, + appSettingsRepository: appSettingsRepository, userContentPreferencesRepository: userContentPreferencesRepository, remoteConfigRepository: remoteConfigRepository, environment: environment, @@ -578,7 +511,7 @@ Future bootstrap( countriesRepository: countriesRepository, sourcesRepository: sourcesRepository, remoteConfigRepository: remoteConfigRepository, - userAppSettingsRepository: userAppSettingsRepository, + appSettingsRepository: appSettingsRepository, userContentPreferencesRepository: userContentPreferencesRepository, pushNotificationService: pushNotificationService, inAppNotificationRepository: inAppNotificationRepository, diff --git a/lib/discover/bloc/source_list_bloc.dart b/lib/discover/bloc/source_list_bloc.dart index 32209295..3740eddb 100644 --- a/lib/discover/bloc/source_list_bloc.dart +++ b/lib/discover/bloc/source_list_bloc.dart @@ -196,8 +196,8 @@ class SourceListBloc extends Bloc { state.copyWith( status: SourceListStatus.loading, selectedCountries: event.selectedCountries, - sources: [], // Clear existing sources - nextCursor: null, // Reset pagination + sources: [], + nextCursor: null, ), ); diff --git a/lib/entity_details/bloc/entity_details_bloc.dart b/lib/entity_details/bloc/entity_details_bloc.dart index 687d184c..4bd1768d 100644 --- a/lib/entity_details/bloc/entity_details_bloc.dart +++ b/lib/entity_details/bloc/entity_details_bloc.dart @@ -125,11 +125,11 @@ class EntityDetailsBloc extends Bloc { // This method injects stateless `AdPlaceholder` markers into the feed. // The full ad loading and lifecycle is managed by the UI layer. // See `AdService` for a detailed explanation. - final processedFeedItems = await _adService.injectAdPlaceholders( + final processedFeedItems = await _adService.injectFeedAdPlaceholders( feedItems: headlineResponse.items, user: currentUser, - adConfig: remoteConfig.adConfig, - imageStyle: _appBloc.state.headlineImageStyle, + remoteConfig: remoteConfig, + imageStyle: _appBloc.state.feedItemImageStyle, adThemeStyle: event.adThemeStyle, ); @@ -298,11 +298,11 @@ class EntityDetailsBloc extends Bloc { // This method injects stateless `AdPlaceholder` markers into the feed. // The full ad loading and lifecycle is managed by the UI layer. // See `FeedDecoratorService` for a detailed explanation. - final newProcessedFeedItems = await _adService.injectAdPlaceholders( + final newProcessedFeedItems = await _adService.injectFeedAdPlaceholders( feedItems: headlineResponse.items, user: currentUser, - adConfig: remoteConfig.adConfig, - imageStyle: _appBloc.state.headlineImageStyle, + remoteConfig: remoteConfig, + imageStyle: _appBloc.state.feedItemImageStyle, // Use the AdThemeStyle passed directly from the UI via the event. // This ensures that ads are styled consistently with the current, // fully-resolved theme of the widget, preventing visual discrepancies. diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index 9a2f687a..d574bc28 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -5,17 +5,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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/services/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/widgets/feed_ad_loader_widget.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/entity_details/bloc/entity_details_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.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:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/content_limitation_bottom_sheet.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/feed_core.dart'; -import 'package:go_router/go_router.dart'; import 'package:ui_kit/ui_kit.dart'; class EntityDetailsPageArguments { @@ -248,18 +245,6 @@ class _EntityDetailsViewState extends State { ], ); - Future onHeadlineTap(Headline headline) async { - await context.read().onPotentialAdTrigger(); - - if (!context.mounted) return; - - await context.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': headline.id}, - extra: headline, - ); - } - return CustomScrollView( controller: _scrollController, slivers: [ @@ -325,23 +310,35 @@ class _EntityDetailsViewState extends State { final imageStyle = context .read() .state - .headlineImageStyle; + .feedItemImageStyle; Widget tile; switch (imageStyle) { - case HeadlineImageStyle.hidden: + case FeedItemImageStyle.hidden: tile = HeadlineTileTextOnly( headline: item, - onHeadlineTap: () => onHeadlineTap(item), + onHeadlineTap: () => + HeadlineTapHandler.handleHeadlineTap( + context, + item, + ), ); - case HeadlineImageStyle.smallThumbnail: + case FeedItemImageStyle.smallThumbnail: tile = HeadlineTileImageStart( headline: item, - onHeadlineTap: () => onHeadlineTap(item), + onHeadlineTap: () => + HeadlineTapHandler.handleHeadlineTap( + context, + item, + ), ); - case HeadlineImageStyle.largeThumbnail: + case FeedItemImageStyle.largeThumbnail: tile = HeadlineTileImageTop( headline: item, - onHeadlineTap: () => onHeadlineTap(item), + onHeadlineTap: () => + HeadlineTapHandler.handleHeadlineTap( + context, + item, + ), ); } return tile; @@ -349,14 +346,13 @@ class _EntityDetailsViewState extends State { // Retrieve the user's preferred headline image style from the AppBloc. // This is the single source of truth for this setting. // Access the AppBloc to get the remoteConfig for ads. - final adConfig = context + final remoteConfig = context .read() .state - .remoteConfig - ?.adConfig; + .remoteConfig; // Ensure adConfig is not null before building the AdLoaderWidget. - if (adConfig == null) { + if (remoteConfig?.features.ads == null) { // Return an empty widget or a placeholder if adConfig is not available. return const SizedBox.shrink(); } @@ -367,7 +363,7 @@ class _EntityDetailsViewState extends State { adThemeStyle: AdThemeStyle.fromTheme( Theme.of(context), ), - adConfig: adConfig, + remoteConfig: remoteConfig!, ); } return const SizedBox.shrink(); diff --git a/lib/feed_decorators/services/feed_decorator_service.dart b/lib/feed_decorators/services/feed_decorator_service.dart index d943b7f2..5c396353 100644 --- a/lib/feed_decorators/services/feed_decorator_service.dart +++ b/lib/feed_decorators/services/feed_decorator_service.dart @@ -48,9 +48,8 @@ class FeedDecoratorService { }) { final decoratedFeed = List.from(feedItems); - final areDecoratorsEnabled = remoteConfig.feedDecoratorConfig.values.any( - (config) => config.enabled, - ); + final areDecoratorsEnabled = remoteConfig.features.feed.decorators.values + .any((config) => config.enabled); if (areDecoratorsEnabled) { _logger.info('Feed decorators enabled. Injecting placeholder.'); diff --git a/lib/feed_decorators/widgets/call_to_action_decorator_widget.dart b/lib/feed_decorators/widgets/call_to_action_decorator_widget.dart index 3eeff755..e935aac6 100644 --- a/lib/feed_decorators/widgets/call_to_action_decorator_widget.dart +++ b/lib/feed_decorators/widgets/call_to_action_decorator_widget.dart @@ -71,11 +71,12 @@ class CallToActionDecoratorWidget extends StatelessWidget { ), ), if (onDismiss != null) - Positioned( + Positioned.directional( top: AppSpacing.xs, - right: AppSpacing.xs, + end: AppSpacing.xs, + textDirection: Directionality.of(context), child: PopupMenuButton( - icon: const Icon(Icons.more_vert), + icon: const Icon(Icons.more_horiz), tooltip: l10n.decoratorDismissAction, onSelected: (_) => onDismiss!(), itemBuilder: (BuildContext context) => [ diff --git a/lib/feed_decorators/widgets/content_collection_decorator_widget.dart b/lib/feed_decorators/widgets/content_collection_decorator_widget.dart index 79ffdb3d..99ddcbdc 100644 --- a/lib/feed_decorators/widgets/content_collection_decorator_widget.dart +++ b/lib/feed_decorators/widgets/content_collection_decorator_widget.dart @@ -113,16 +113,19 @@ class _ContentCollectionViewState extends State<_ContentCollectionView> { ), ), if (widget.onDismiss != null) - PopupMenuButton( - icon: const Icon(Icons.more_vert), - tooltip: l10n.decoratorDismissAction, - onSelected: (_) => widget.onDismiss!(), - itemBuilder: (BuildContext context) => [ - PopupMenuItem( - value: 'dismiss', - child: Text(l10n.decoratorDismissAction), - ), - ], + Directionality( + textDirection: Directionality.of(context), + child: PopupMenuButton( + icon: const Icon(Icons.more_horiz), + tooltip: l10n.decoratorDismissAction, + onSelected: (_) => widget.onDismiss!(), + itemBuilder: (BuildContext context) => [ + PopupMenuItem( + value: 'dismiss', + child: Text(l10n.decoratorDismissAction), + ), + ], + ), ), ], ), diff --git a/lib/feed_decorators/widgets/feed_decorator_loader_widget.dart b/lib/feed_decorators/widgets/feed_decorator_loader_widget.dart index 0c7263b6..1957ea08 100644 --- a/lib/feed_decorators/widgets/feed_decorator_loader_widget.dart +++ b/lib/feed_decorators/widgets/feed_decorator_loader_widget.dart @@ -124,7 +124,8 @@ class _FeedDecoratorLoaderWidgetState extends State { return; } - final decoratorConfig = remoteConfig.feedDecoratorConfig[dueDecoratorType]; + final decoratorConfig = + remoteConfig.features.feed.decorators[dueDecoratorType]; if (decoratorConfig == null) { _logger.warning('Config not found for due decorator: $dueDecoratorType'); if (mounted) setState(() => _state = _DecoratorState.none); @@ -172,7 +173,7 @@ class _FeedDecoratorLoaderWidgetState extends State { ); } else if (decoratorItem is ContentCollectionItem) { _decoratorWidget = ContentCollectionDecoratorWidget( - item: decoratorItem, // The content collection item to display. + item: decoratorItem, // The onFollowToggle callback is handled by this widget, which // then dispatches an event to the AppBloc to update user preferences. onFollowToggle: _onFollowToggle, @@ -300,7 +301,7 @@ class _FeedDecoratorLoaderWidgetState extends State { final userRole = user.appRole; final dueCandidates = <({FeedDecoratorType type, int priority})>[]; - for (final entry in remoteConfig.feedDecoratorConfig.entries) { + for (final entry in remoteConfig.features.feed.decorators.entries) { final decoratorType = entry.key; final decoratorConfig = entry.value; diff --git a/lib/headline-details/bloc/headline_details_bloc.dart b/lib/headline-details/bloc/headline_details_bloc.dart deleted file mode 100644 index 62048f3e..00000000 --- a/lib/headline-details/bloc/headline_details_bloc.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'dart:async'; - -import 'package:bloc/bloc.dart'; -import 'package:core/core.dart' show Headline, HttpException, UnknownException; -import 'package:data_repository/data_repository.dart'; -import 'package:equatable/equatable.dart'; - -part 'headline_details_event.dart'; -part 'headline_details_state.dart'; - -class HeadlineDetailsBloc - extends Bloc { - HeadlineDetailsBloc({required DataRepository headlinesRepository}) - : _headlinesRepository = headlinesRepository, - super(HeadlineDetailsInitial()) { - on(_onFetchHeadlineById); - on(_onHeadlineProvided); - } - - final DataRepository _headlinesRepository; - - Future _onFetchHeadlineById( - FetchHeadlineById event, - Emitter emit, - ) async { - emit(HeadlineDetailsLoading()); - try { - final headline = await _headlinesRepository.read(id: event.headlineId); - emit(HeadlineDetailsLoaded(headline: headline)); - } on HttpException catch (e) { - emit(HeadlineDetailsFailure(exception: e)); - } catch (e) { - emit( - HeadlineDetailsFailure( - exception: UnknownException('An unexpected error occurred: $e'), - ), - ); - } - } - - void _onHeadlineProvided( - HeadlineProvided event, - Emitter emit, - ) { - emit(HeadlineDetailsLoaded(headline: event.headline)); - } -} diff --git a/lib/headline-details/bloc/headline_details_event.dart b/lib/headline-details/bloc/headline_details_event.dart deleted file mode 100644 index 7128bca4..00000000 --- a/lib/headline-details/bloc/headline_details_event.dart +++ /dev/null @@ -1,24 +0,0 @@ -part of 'headline_details_bloc.dart'; - -abstract class HeadlineDetailsEvent extends Equatable { - const HeadlineDetailsEvent(); - - @override - List get props => []; -} - -class FetchHeadlineById extends HeadlineDetailsEvent { - const FetchHeadlineById(this.headlineId); - final String headlineId; - - @override - List get props => [headlineId]; -} - -class HeadlineProvided extends HeadlineDetailsEvent { - const HeadlineProvided(this.headline); - final Headline headline; - - @override - List get props => [headline]; -} diff --git a/lib/headline-details/bloc/headline_details_state.dart b/lib/headline-details/bloc/headline_details_state.dart deleted file mode 100644 index ff7b928e..00000000 --- a/lib/headline-details/bloc/headline_details_state.dart +++ /dev/null @@ -1,30 +0,0 @@ -part of 'headline_details_bloc.dart'; - -abstract class HeadlineDetailsState extends Equatable { - const HeadlineDetailsState(); - - @override - List get props => []; -} - -class HeadlineDetailsInitial extends HeadlineDetailsState {} - -class HeadlineDetailsLoading extends HeadlineDetailsState {} - -class HeadlineDetailsLoaded extends HeadlineDetailsState { - const HeadlineDetailsLoaded({required this.headline}); - - final Headline headline; - - @override - List get props => [headline]; -} - -class HeadlineDetailsFailure extends HeadlineDetailsState { - const HeadlineDetailsFailure({required this.exception}); - - final HttpException exception; - - @override - List get props => [exception]; -} diff --git a/lib/headline-details/bloc/similar_headlines_bloc.dart b/lib/headline-details/bloc/similar_headlines_bloc.dart deleted file mode 100644 index 0f3db6bd..00000000 --- a/lib/headline-details/bloc/similar_headlines_bloc.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'dart:async'; - -import 'package:bloc/bloc.dart'; -import 'package:core/core.dart' - show - ContentStatus, - Headline, - HttpException, - PaginationOptions, - SortOption, - SortOrder; -import 'package:data_repository/data_repository.dart'; -import 'package:equatable/equatable.dart'; - -part 'similar_headlines_event.dart'; -part 'similar_headlines_state.dart'; - -class SimilarHeadlinesBloc - extends Bloc { - SimilarHeadlinesBloc({required DataRepository headlinesRepository}) - : _headlinesRepository = headlinesRepository, - super(SimilarHeadlinesInitial()) { - on(_onFetchSimilarHeadlines); - } - - final DataRepository _headlinesRepository; - static const int _similarHeadlinesLimit = 5; - - Future _onFetchSimilarHeadlines( - FetchSimilarHeadlines event, - Emitter emit, - ) async { - emit(SimilarHeadlinesLoading()); - try { - final currentHeadline = event.currentHeadline; - - // Filter by topic ID and ensure only active headlines are fetched. - final filter = { - 'topic.id': currentHeadline.topic.id, - 'status': ContentStatus.active.name, - }; - - final response = await _headlinesRepository.readAll( - filter: filter, - sort: [const SortOption('updatedAt', SortOrder.desc)], - // Fetch one extra to check if current is there - pagination: const PaginationOptions(limit: _similarHeadlinesLimit + 1), - ); - - // Filter out the current headline from the results - final similarHeadlines = response.items - .where((headline) => headline.id != currentHeadline.id) - .toList(); - - // Take only the required limit after filtering - final finalSimilarHeadlines = similarHeadlines - .take(_similarHeadlinesLimit) - .toList(); - - if (finalSimilarHeadlines.isEmpty) { - emit(SimilarHeadlinesEmpty()); - } else { - emit(SimilarHeadlinesLoaded(similarHeadlines: finalSimilarHeadlines)); - } - } on HttpException catch (e) { - emit(SimilarHeadlinesError(message: e.message)); - } catch (e) { - emit(SimilarHeadlinesError(message: 'An unexpected error occurred: $e')); - } - } -} diff --git a/lib/headline-details/bloc/similar_headlines_event.dart b/lib/headline-details/bloc/similar_headlines_event.dart deleted file mode 100644 index 6d72f577..00000000 --- a/lib/headline-details/bloc/similar_headlines_event.dart +++ /dev/null @@ -1,17 +0,0 @@ -part of 'similar_headlines_bloc.dart'; - -abstract class SimilarHeadlinesEvent extends Equatable { - const SimilarHeadlinesEvent(); - - @override - List get props => []; -} - -class FetchSimilarHeadlines extends SimilarHeadlinesEvent { - const FetchSimilarHeadlines({required this.currentHeadline}); - - final Headline currentHeadline; - - @override - List get props => [currentHeadline]; -} diff --git a/lib/headline-details/bloc/similar_headlines_state.dart b/lib/headline-details/bloc/similar_headlines_state.dart deleted file mode 100644 index 4a14a65d..00000000 --- a/lib/headline-details/bloc/similar_headlines_state.dart +++ /dev/null @@ -1,32 +0,0 @@ -part of 'similar_headlines_bloc.dart'; - -abstract class SimilarHeadlinesState extends Equatable { - const SimilarHeadlinesState(); - - @override - List get props => []; -} - -class SimilarHeadlinesInitial extends SimilarHeadlinesState {} - -class SimilarHeadlinesLoading extends SimilarHeadlinesState {} - -class SimilarHeadlinesLoaded extends SimilarHeadlinesState { - const SimilarHeadlinesLoaded({required this.similarHeadlines}); - - final List similarHeadlines; - - @override - List get props => [similarHeadlines]; -} - -class SimilarHeadlinesEmpty extends SimilarHeadlinesState {} - -class SimilarHeadlinesError extends SimilarHeadlinesState { - const SimilarHeadlinesError({required this.message}); - - final String message; - - @override - List get props => [message]; -} diff --git a/lib/headline-details/view/headline_details_page.dart b/lib/headline-details/view/headline_details_page.dart deleted file mode 100644 index 06c426da..00000000 --- a/lib/headline-details/view/headline_details_page.dart +++ /dev/null @@ -1,768 +0,0 @@ -// -// ignore_for_file: avoid_redundant_argument_values - -import 'package:core/core.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/in_app_notification_center_bloc.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/services/interstitial_ad_manager.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'; -import 'package:flutter_news_app_mobile_client_full_source_code/headline-details/bloc/headline_details_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/headline-details/bloc/similar_headlines_bloc.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:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/content_limitation_bottom_sheet.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/feed_core.dart'; -import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:ui_kit/ui_kit.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -class HeadlineDetailsPage extends StatefulWidget { - const HeadlineDetailsPage({ - super.key, - this.headlineId, - this.initialHeadline, - this.notificationId, - }) : assert(headlineId != null || initialHeadline != null); - - final String? headlineId; - final Headline? initialHeadline; - // The ID of the in-app notification that triggered this navigation. - final String? notificationId; - - @override - State createState() => _HeadlineDetailsPageState(); -} - -class _HeadlineDetailsPageState extends State { - final _metadataChipsScrollController = ScrollController(); - - @override - void initState() { - super.initState(); - _metadataChipsScrollController.addListener(() => setState(() {})); - if (widget.initialHeadline != null) { - context.read().add( - HeadlineProvided(widget.initialHeadline!), - ); - context.read().add( - FetchSimilarHeadlines(currentHeadline: widget.initialHeadline!), - ); - } else if (widget.headlineId != null) { - context.read().add( - FetchHeadlineById(widget.headlineId!), - ); - } - - // If a notificationId is provided, it means the user deep-linked from a - // push notification. We dispatch an event to mark that specific - // notification as read. - if (widget.notificationId != null) { - context.read().add( - InAppNotificationCenterMarkOneAsRead(widget.notificationId!), - ); - } - } - - @override - void dispose() { - _metadataChipsScrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - - return BlocListener( - listener: (context, headlineState) { - if (headlineState is HeadlineDetailsLoaded) { - if (widget.initialHeadline == null) { - context.read().add( - FetchSimilarHeadlines(currentHeadline: headlineState.headline), - ); - } - } - }, - child: SafeArea( - child: Scaffold( - body: BlocListener( - listenWhen: (previous, current) { - final detailsState = context.read().state; - if (detailsState is HeadlineDetailsLoaded) { - final currentHeadlineId = detailsState.headline.id; - final wasPreviouslySaved = - previous.userContentPreferences?.savedHeadlines.any( - (h) => h.id == currentHeadlineId, - ) ?? - false; - final isCurrentlySaved = - current.userContentPreferences?.savedHeadlines.any( - (h) => h.id == currentHeadlineId, - ) ?? - false; - - // Listen for changes in saved status or for a new error. - return (wasPreviouslySaved != isCurrentlySaved) || - (current.error != null && previous.error == null); - } - return false; - }, - listener: (context, appState) { - final detailsState = context.read().state; - if (detailsState is HeadlineDetailsLoaded) { - if (appState.error != null) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text(l10n.headlineSaveErrorSnackbar), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); - } else { - final nowIsSaved = - appState.userContentPreferences?.savedHeadlines.any( - (h) => h.id == detailsState.headline.id, - ) ?? - false; - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - nowIsSaved - ? l10n.headlineSavedSuccessSnackbar - : l10n.headlineUnsavedSuccessSnackbar, - ), - duration: const Duration(seconds: 2), - ), - ); - } - } - }, - child: BlocBuilder( - builder: (context, state) { - return switch (state) { - HeadlineDetailsInitial() || - HeadlineDetailsLoading() => LoadingStateWidget( - icon: Icons.article_outlined, - headline: l10n.headlineDetailsLoadingHeadline, - subheadline: l10n.headlineDetailsLoadingSubheadline, - ), - final HeadlineDetailsFailure failureState => - FailureStateWidget( - exception: failureState.exception, - onRetry: () { - if (widget.headlineId != null) { - context.read().add( - FetchHeadlineById(widget.headlineId!), - ); - } - }, - ), - final HeadlineDetailsLoaded loadedState => - _buildLoadedContent(context, loadedState.headline), - _ => Center( - child: Text( - l10n.unknownError, - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - }; - }, - ), - ), - ), - ), - ); - } - - Widget _buildLoadedContent(BuildContext context, Headline headline) { - final l10n = AppLocalizationsX(context).l10n; - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final colorScheme = theme.colorScheme; - - const horizontalPadding = EdgeInsets.symmetric( - horizontal: AppSpacing.paddingLarge, - ); - - final appBlocState = context.watch().state; - final isSaved = - appBlocState.userContentPreferences?.savedHeadlines.any( - (h) => h.id == headline.id, - ) ?? - false; - - final bookmarkButton = IconButton( - icon: Icon( - isSaved ? Icons.bookmark : Icons.bookmark_border_outlined, - color: colorScheme.primary, - ), - tooltip: isSaved - ? l10n.headlineDetailsRemoveFromSavedTooltip - : l10n.headlineDetailsSaveTooltip, - onPressed: () { - final currentPreferences = appBlocState.userContentPreferences; - if (currentPreferences == null) { - // Handle case where preferences are not loaded (e.g., show error) - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text(l10n.headlineSaveErrorSnackbar), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); - return; - } - - // If the user is un-saving, always allow it. - if (isSaved) { - final updatedSavedHeadlines = currentPreferences.savedHeadlines - .where((h) => h.id != headline.id) - .toList(); - final updatedPreferences = currentPreferences.copyWith( - savedHeadlines: updatedSavedHeadlines, - ); - context.read().add( - AppUserContentPreferencesChanged(preferences: updatedPreferences), - ); - } else { - // If the user is saving, check the limit first. - final limitationService = context.read(); - final status = limitationService.checkAction( - ContentAction.bookmarkHeadline, - ); - - if (status == LimitationStatus.allowed) { - final updatedSavedHeadlines = List.from( - currentPreferences.savedHeadlines, - )..add(headline); - final updatedPreferences = currentPreferences.copyWith( - savedHeadlines: updatedSavedHeadlines, - ); - context.read().add( - AppUserContentPreferencesChanged(preferences: updatedPreferences), - ); - } else { - // If the limit is reached, show the bottom sheet. - showModalBottomSheet( - context: context, - builder: (_) => ContentLimitationBottomSheet(status: status), - ); - } - } - }, - ); - - final Widget shareButtonWidget = Builder( - builder: (BuildContext buttonContext) { - return IconButton( - icon: Icon(Icons.share_outlined, color: colorScheme.primary), - tooltip: l10n.shareActionTooltip, - onPressed: () async { - final box = buttonContext.findRenderObject() as RenderBox?; - Rect? sharePositionOrigin; - if (box != null) { - sharePositionOrigin = box.localToGlobal(Offset.zero) & box.size; - } - ShareParams params; - if (kIsWeb && headline.url.isNotEmpty) { - params = ShareParams( - uri: Uri.parse(headline.url), - title: headline.title, - sharePositionOrigin: sharePositionOrigin, - ); - } else if (headline.url.isNotEmpty) { - params = ShareParams( - text: '${headline.title}\n\n${headline.url}', - subject: headline.title, - sharePositionOrigin: sharePositionOrigin, - ); - } else { - params = ShareParams( - text: headline.title, - subject: headline.title, - sharePositionOrigin: sharePositionOrigin, - ); - } - final shareResult = await SharePlus.instance.share(params); - if (buttonContext.mounted) { - if (shareResult.status == ShareResultStatus.unavailable) { - ScaffoldMessenger.of(buttonContext).showSnackBar( - SnackBar(content: Text(l10n.sharingUnavailableSnackbar)), - ); - } - } - }, - ); - }, - ); - - final adConfig = appBlocState.remoteConfig?.adConfig; - final adThemeStyle = AdThemeStyle.fromTheme(Theme.of(context)); - final userRole = appBlocState.user?.appRole ?? AppUserRole.guestUser; - - Future onEntityChipTap(ContentType type, String id) async { - // Await for the ad to be shown and dismissed. - await context.read().onPotentialAdTrigger(); - - // Check if the widget is still in the tree before navigating. - if (!context.mounted) return; - - // Proceed with navigation after the ad is closed. - await context.pushNamed( - Routes.entityDetailsName, - pathParameters: {'type': type.name, 'id': id}, - ); - } - - final slivers = [ - SliverAppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - onPressed: () => context.pop(), - color: colorScheme.onSurface, - ), - actions: [ - bookmarkButton, - shareButtonWidget, - const SizedBox(width: AppSpacing.sm), - ], - pinned: false, - floating: true, - snap: true, - backgroundColor: Colors.transparent, - elevation: 0, - foregroundColor: colorScheme.onSurface, - ), - SliverPadding( - padding: horizontalPadding.copyWith(top: AppSpacing.sm), - sliver: SliverToBoxAdapter( - child: Text( - headline.title, - style: textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - ), - SliverPadding( - padding: EdgeInsets.only( - top: AppSpacing.md, - left: horizontalPadding.left, - right: horizontalPadding.right, - ), - sliver: SliverToBoxAdapter( - child: ClipRRect( - borderRadius: BorderRadius.circular(AppSpacing.md), - child: AspectRatio( - aspectRatio: 16 / 9, - child: Image.network( - headline.imageUrl, - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return ColoredBox( - color: colorScheme.surfaceContainerHighest, - child: const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - }, - errorBuilder: (context, error, stackTrace) => ColoredBox( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.broken_image_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xxl * 1.5, - ), - ), - ), - ), - ), - ), - ), - SliverPadding( - padding: const EdgeInsets.only(top: AppSpacing.lg), - sliver: SliverToBoxAdapter( - child: SizedBox( - height: 36, - child: LayoutBuilder( - builder: (context, constraints) { - final chips = _buildMetadataChips( - context, - headline, - onEntityChipTap, - ); - - final listView = ListView.separated( - controller: _metadataChipsScrollController, - scrollDirection: Axis.horizontal, - itemCount: chips.length, - separatorBuilder: (context, index) => - const SizedBox(width: AppSpacing.sm), - itemBuilder: (context, index) => chips[index], - // Apply horizontal padding directly to the ListView - ); - - // Determine if the fade should be shown based on scroll position. - var showStartFade = false; - var showEndFade = false; - if (_metadataChipsScrollController.hasClients && - _metadataChipsScrollController.position.maxScrollExtent > - 0) { - final pixels = _metadataChipsScrollController.position.pixels; - final minScroll = - _metadataChipsScrollController.position.minScrollExtent; - final maxScroll = - _metadataChipsScrollController.position.maxScrollExtent; - - if (pixels > minScroll) showStartFade = true; - if (pixels < maxScroll) showEndFade = true; - } - - final colors = [ - if (showStartFade) Colors.transparent, - theme.scaffoldBackgroundColor, - theme.scaffoldBackgroundColor, - if (showEndFade) Colors.transparent, - ]; - - final stops = [ - if (showStartFade) 0.0, - if (showStartFade) 0.02 else 0.0, - if (showEndFade) 0.98 else 1.0, - if (showEndFade) 1.0, - ]; - - return ShaderMask( - shaderCallback: (bounds) => LinearGradient( - colors: colors, - stops: stops, - ).createShader(bounds), - blendMode: BlendMode.dstIn, - // Apply padding here to ensure the ShaderMask is applied - // to the correctly padded content area. - child: Padding(padding: horizontalPadding, child: listView), - ); - }, - ), - ), - ), - ), - if (headline.excerpt.isNotEmpty) - SliverPadding( - padding: horizontalPadding.copyWith(top: AppSpacing.lg), - sliver: SliverToBoxAdapter( - child: Text( - headline.excerpt, - style: textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - height: 1.6, - ), - ), - ), - ), - ]; - - // Add ad above continue reading button if configured - final isAboveButtonAdVisible = - adConfig != null && - adConfig.enabled && - adConfig.articleAdConfiguration.enabled && - (adConfig - .articleAdConfiguration - .visibleTo[userRole]?[InArticleAdSlotType - .aboveArticleContinueReadingButton] ?? - false); - - if (isAboveButtonAdVisible) { - slivers.add( - SliverToBoxAdapter( - child: Column( - children: [ - const SizedBox(height: AppSpacing.lg), - Padding( - padding: horizontalPadding, - child: InArticleAdLoaderWidget( - slotType: - InArticleAdSlotType.aboveArticleContinueReadingButton, - adThemeStyle: adThemeStyle, - adConfig: adConfig, - ), - ), - ], - ), - ), - ); - } else { - slivers.add( - const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.lg)), - ); - } - - slivers.addAll([ - if (headline.url.isNotEmpty) - SliverPadding( - padding: horizontalPadding, - sliver: SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: AppSpacing.lg), - SizedBox( - height: 48, - child: FilledButton.icon( - icon: const Icon(Icons.open_in_new_outlined), - onPressed: () async { - await launchUrlString(headline.url); - }, - label: Text(l10n.headlineDetailsContinueReadingButton), - style: FilledButton.styleFrom( - textStyle: textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppSpacing.sm), - ), - ), - ), - ), - ], - ), - ), - ), - if (headline.url.isEmpty) - const SliverToBoxAdapter(child: SizedBox.shrink()), - ]); - - // Add ad below continue reading button if configured - final isBelowButtonAdVisible = - adConfig != null && - adConfig.enabled && - adConfig.articleAdConfiguration.enabled && - (adConfig - .articleAdConfiguration - .visibleTo[userRole]?[InArticleAdSlotType - .belowArticleContinueReadingButton] ?? - false); - - if (isBelowButtonAdVisible) { - slivers.add( - SliverToBoxAdapter( - child: Column( - children: [ - const SizedBox(height: AppSpacing.lg), - Padding( - padding: horizontalPadding, - child: InArticleAdLoaderWidget( - slotType: - InArticleAdSlotType.belowArticleContinueReadingButton, - adThemeStyle: adThemeStyle, - adConfig: adConfig, - ), - ), - ], - ), - ), - ); - } else { - slivers.add( - const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.lg)), - ); - } - - Future onSimilarHeadlineTap(Headline similarHeadline) async { - // Await for the ad to be shown and dismissed. - await context.read().onPotentialAdTrigger(); - - // Check if the widget is still in the tree before navigating. - if (!context.mounted) return; - - // Proceed with navigation after the ad is closed. - await context.pushNamed( - Routes.globalArticleDetailsName, - pathParameters: {'id': similarHeadline.id}, - extra: similarHeadline, - ); - } - - slivers.add( - BlocBuilder( - builder: (context, state) { - if (state is SimilarHeadlinesLoaded && - state.similarHeadlines.isEmpty || - state is SimilarHeadlinesEmpty) { - return const SliverToBoxAdapter(child: SizedBox.shrink()); - } - return SliverMainAxisGroup( - slivers: [ - const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.xl)), - SliverPadding( - padding: horizontalPadding, - sliver: SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.md), - child: Text( - l10n.similarHeadlinesSectionTitle, - style: textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - _buildSimilarHeadlinesSection( - context, - horizontalPadding, - onSimilarHeadlineTap, - ), - ], - ); - }, - ), - ); - - return CustomScrollView(slivers: slivers); - } - - List _buildMetadataChips( - BuildContext context, - Headline headline, - void Function(ContentType, String) onEntityChipTap, - ) { - final theme = Theme.of(context); - - Widget buildChip({required String label, VoidCallback? onPressed}) { - return ActionChip( - label: Text(label), - // Use default theme styles for a cleaner look. - labelStyle: theme.textTheme.labelMedium, - onPressed: onPressed, - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xs, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppSpacing.md), - side: BorderSide.none, - ), - ); - } - - return [ - buildChip( - label: DateFormat('yyyy-MM-dd').format(headline.createdAt), - onPressed: null, // This makes the chip non-interactive. - ), - buildChip( - label: headline.source.name, - onPressed: () => - onEntityChipTap(ContentType.source, headline.source.id), - ), - buildChip( - label: headline.topic.name, - onPressed: () => onEntityChipTap(ContentType.topic, headline.topic.id), - ), - buildChip( - label: headline.eventCountry.name, - onPressed: () => - onEntityChipTap(ContentType.country, headline.eventCountry.id), - ), - ]; - } - - Widget _buildSimilarHeadlinesSection( - BuildContext context, - EdgeInsets hPadding, - void Function(Headline headline) onSimilarHeadlineTap, - ) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final colorScheme = theme.colorScheme; - - return BlocBuilder( - builder: (context, state) { - return switch (state) { - SimilarHeadlinesInitial() || - SimilarHeadlinesLoading() => const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.symmetric(vertical: AppSpacing.xl), - child: Center(child: CircularProgressIndicator()), - ), - ), - final SimilarHeadlinesError errorState => SliverToBoxAdapter( - child: Padding( - padding: hPadding.copyWith( - top: AppSpacing.md, - bottom: AppSpacing.xl, - ), - child: Text( - errorState.message, - textAlign: TextAlign.center, - style: textTheme.bodyMedium?.copyWith(color: colorScheme.error), - ), - ), - ), - SimilarHeadlinesEmpty() => const SliverToBoxAdapter( - child: SizedBox.shrink(), - ), - final SimilarHeadlinesLoaded loadedState => SliverPadding( - padding: hPadding.copyWith(bottom: AppSpacing.xxl), - sliver: SliverList.separated( - separatorBuilder: (context, index) => - const SizedBox(height: AppSpacing.sm), - itemCount: loadedState.similarHeadlines.length, - itemBuilder: (context, index) { - // Corrected: SliverList.separated uses itemBuilder - final similarHeadline = loadedState.similarHeadlines[index]; - return Builder( - builder: (context) { - final imageStyle = context - .watch() - .state - .headlineImageStyle; - Widget tile; - switch (imageStyle) { - case HeadlineImageStyle.hidden: - tile = HeadlineTileTextOnly( - headline: similarHeadline, - onHeadlineTap: () => - onSimilarHeadlineTap(similarHeadline), - ); - case HeadlineImageStyle.smallThumbnail: - tile = HeadlineTileImageStart( - headline: similarHeadline, - onHeadlineTap: () => - onSimilarHeadlineTap(similarHeadline), - ); - case HeadlineImageStyle.largeThumbnail: - tile = HeadlineTileImageTop( - headline: similarHeadline, - onHeadlineTap: () => - onSimilarHeadlineTap(similarHeadline), - ); - } - return tile; - }, - ); - }, - ), - ), - _ => const SliverToBoxAdapter(child: SizedBox.shrink()), - }; - }, - ); - } -} diff --git a/lib/headlines-feed/bloc/headlines_feed_bloc.dart b/lib/headlines-feed/bloc/headlines_feed_bloc.dart index b8dee8bd..98b43bdc 100644 --- a/lib/headlines-feed/bloc/headlines_feed_bloc.dart +++ b/lib/headlines-feed/bloc/headlines_feed_bloc.dart @@ -258,11 +258,11 @@ class HeadlinesFeedBloc extends Bloc { ); // For pagination, only inject ad placeholders. - final newProcessedFeedItems = await _adService.injectAdPlaceholders( + final newProcessedFeedItems = await _adService.injectFeedAdPlaceholders( feedItems: headlineResponse.items, user: currentUser, - adConfig: remoteConfig.adConfig, - imageStyle: _appBloc.state.settings!.feedPreferences.headlineImageStyle, + remoteConfig: remoteConfig, + imageStyle: _appBloc.state.settings!.feedSettings.feedItemImageStyle, adThemeStyle: event.adThemeStyle, processedContentItemCount: cachedFeed.feedItems .whereType() @@ -319,7 +319,7 @@ class HeadlinesFeedBloc extends Bloc { 'Refresh throttled for filter "$filterKey". ' 'Time since last: $timeSinceLastRefresh.', ); - return; // Ignore the request. + return; } } @@ -424,11 +424,11 @@ class HeadlinesFeedBloc extends Bloc { ); // Step 2: Inject ad placeholders into the resulting list. - final fullyDecoratedFeed = await _adService.injectAdPlaceholders( + final fullyDecoratedFeed = await _adService.injectFeedAdPlaceholders( feedItems: feedWithDecorator, user: currentUser, - adConfig: appConfig.adConfig, - imageStyle: settings!.feedPreferences.headlineImageStyle, + remoteConfig: appConfig, + imageStyle: settings!.feedSettings.feedItemImageStyle, adThemeStyle: event.adThemeStyle, ); @@ -568,11 +568,11 @@ class HeadlinesFeedBloc extends Bloc { ); // Step 2: Inject ad placeholders into the resulting list. - final fullyDecoratedFeed = await _adService.injectAdPlaceholders( + final fullyDecoratedFeed = await _adService.injectFeedAdPlaceholders( feedItems: feedWithDecorator, user: currentUser, - adConfig: appConfig.adConfig, - imageStyle: settings!.feedPreferences.headlineImageStyle, + remoteConfig: appConfig, + imageStyle: settings!.feedSettings.feedItemImageStyle, adThemeStyle: event.adThemeStyle, ); @@ -672,11 +672,11 @@ class HeadlinesFeedBloc extends Bloc { ); // Step 2: Inject ad placeholders into the resulting list. - final fullyDecoratedFeed = await _adService.injectAdPlaceholders( + final fullyDecoratedFeed = await _adService.injectFeedAdPlaceholders( feedItems: feedWithDecorator, user: currentUser, - adConfig: appConfig.adConfig, - imageStyle: settings!.feedPreferences.headlineImageStyle, + remoteConfig: appConfig, + imageStyle: settings!.feedSettings.feedItemImageStyle, adThemeStyle: event.adThemeStyle, ); diff --git a/lib/headlines-feed/view/headline_search_delegate.dart b/lib/headlines-feed/view/headline_search_delegate.dart index 25d1ea04..d89ec4aa 100644 --- a/lib/headlines-feed/view/headline_search_delegate.dart +++ b/lib/headlines-feed/view/headline_search_delegate.dart @@ -3,9 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/headlines_search_bloc.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:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/feed_core.dart'; -import 'package:go_router/go_router.dart'; import 'package:ui_kit/ui_kit.dart'; /// {@template headline_search_delegate} @@ -102,17 +100,8 @@ class HeadlineSearchDelegate extends SearchDelegate { final headline = state.headlines[index]; return HeadlineTileImageStart( headline: headline, - onHeadlineTap: () { - // Navigate to the article details page. - // Using `pushNamed` with the nested route name ensures - // that the "Feed" tab in the bottom navigation bar - // remains selected, providing a consistent UX. - context.pushNamed( - Routes.articleDetailsName, - pathParameters: {'id': headline.id}, - extra: headline, - ); - }, + onHeadlineTap: () => + HeadlineTapHandler.handleHeadlineTap(context, headline), ); }, ); diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index e488e1dd..de818c73 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -11,7 +11,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/b import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/widgets/feed_sliver_app_bar.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/widgets/saved_filters_bar.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/feed_core.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/shared.dart'; import 'package:go_router/go_router.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -262,11 +262,11 @@ class _HeadlinesFeedPageState extends State final imageStyle = context .watch() .state - .headlineImageStyle; - Widget tile; + .feedItemImageStyle; + switch (imageStyle) { - case HeadlineImageStyle.hidden: - tile = HeadlineTileTextOnly( + case FeedItemImageStyle.hidden: + return HeadlineTileTextOnly( headline: item, onHeadlineTap: () => HeadlineTapHandler.handleHeadlineTap( @@ -274,8 +274,8 @@ class _HeadlinesFeedPageState extends State item, ), ); - case HeadlineImageStyle.smallThumbnail: - tile = HeadlineTileImageStart( + case FeedItemImageStyle.smallThumbnail: + return HeadlineTileImageStart( headline: item, onHeadlineTap: () => HeadlineTapHandler.handleHeadlineTap( @@ -283,8 +283,8 @@ class _HeadlinesFeedPageState extends State item, ), ); - case HeadlineImageStyle.largeThumbnail: - tile = HeadlineTileImageTop( + case FeedItemImageStyle.largeThumbnail: + return HeadlineTileImageTop( headline: item, onHeadlineTap: () => HeadlineTapHandler.handleHeadlineTap( @@ -293,17 +293,15 @@ class _HeadlinesFeedPageState extends State ), ); } - return tile; } else if (item is AdPlaceholder) { // Access the AppBloc to get the remoteConfig for ads. - final adConfig = context + final remoteConfig = context .read() .state - .remoteConfig - ?.adConfig; + .remoteConfig; // Ensure adConfig is not null before building the AdLoaderWidget. - if (adConfig == null) { + if (remoteConfig?.features.ads == null) { // Return an empty widget or a placeholder if adConfig is not available. return const SizedBox.shrink(); } @@ -313,7 +311,7 @@ class _HeadlinesFeedPageState extends State contextKey: state.activeFilterId!, adPlaceholder: item, adThemeStyle: AdThemeStyle.fromTheme(theme), - adConfig: adConfig, + remoteConfig: remoteConfig!, ); } else if (item is DecoratorPlaceholder) { // The FeedDecoratorLoaderWidget is responsible for diff --git a/lib/headlines-feed/view/headlines_filter_page.dart b/lib/headlines-feed/view/headlines_filter_page.dart index 9e2ae6f3..2988cb6b 100644 --- a/lib/headlines-feed/view/headlines_filter_page.dart +++ b/lib/headlines-feed/view/headlines_filter_page.dart @@ -219,7 +219,7 @@ Future _createAndApplyFilter(BuildContext context) async { if (didSave == true && context.mounted) { context ..pop() // Pop HeadlinesFilterPage. - ..pop(); // Pop SavedHeadlinesFiltersPage. + ..pop(); } } @@ -313,7 +313,7 @@ void _applyAndExit(BuildContext context) { // close the "Saved Filters" page, returning the user directly to the feed. context ..pop() // Pop HeadlinesFilterPage - ..pop(); // Pop SavedHeadlinesFiltersPage + ..pop(); } class _HeadlinesFilterView extends StatelessWidget { diff --git a/lib/headlines-feed/widgets/save_filter_dialog.dart b/lib/headlines-feed/widgets/save_filter_dialog.dart index 272aa46e..5274bab0 100644 --- a/lib/headlines-feed/widgets/save_filter_dialog.dart +++ b/lib/headlines-feed/widgets/save_filter_dialog.dart @@ -139,6 +139,8 @@ class _SaveFilterDialogState extends State { // If the user denies permission at the OS level, stop. if (!permissionGranted) { // Provide UI feedback to the user. + // Guard against using context across async gaps. + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(l10n.notificationPermissionDeniedError), @@ -160,7 +162,8 @@ class _SaveFilterDialogState extends State { // Pop the dialog and return `true` to signal to the caller that the // save operation was successfully initiated. This allows the caller // to coordinate subsequent navigation actions, preventing race conditions. - Navigator.of(context).pop(true); // Return true on success. + if (!mounted) return; + Navigator.of(context).pop(true); } } @@ -170,7 +173,8 @@ class _SaveFilterDialogState extends State { final isEditing = widget.filterToEdit != null; final pushNotificationConfig = context .select((AppBloc bloc) => bloc.state.remoteConfig) - ?.pushNotificationConfig; + ?.features + .pushNotifications; return AlertDialog( insetPadding: const EdgeInsets.all(AppSpacing.lg), diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 089e41a1..f6367630 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -674,10 +674,10 @@ abstract class AppLocalizations { /// **'Appearance'** String get settingsAppearanceTitle; - /// Title for the feed display settings section/page + /// Title for the feed settings section/page /// /// In en, this message translates to: - /// **'Feed Display'** + /// **'Feed'** String get settingsFeedDisplayTitle; /// Title for the article display settings section/page @@ -2545,6 +2545,60 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Breaking News'** String get breakingNewsPrefix; + + /// Label for the setting that controls how article links are opened. + /// + /// In en, this message translates to: + /// **'Open links using'** + String get settingsFeedClickBehaviorLabel; + + /// Option to use the app's default link-opening behavior. + /// + /// In en, this message translates to: + /// **'App default'** + String get settingsFeedClickBehaviorDefault; + + /// Option to open links in a browser within the app. + /// + /// In en, this message translates to: + /// **'In-app browser'** + String get settingsFeedClickBehaviorInApp; + + /// Option to open links in the device's default system browser. + /// + /// In en, this message translates to: + /// **'System browser'** + String get settingsFeedClickBehaviorSystem; + + /// Button text on an interstitial ad to proceed to the article. + /// + /// In en, this message translates to: + /// **'Continue to Article'** + String get continueToArticleButtonLabel; + + /// Title for the modal bottom sheet that shows headline actions like share and bookmark. + /// + /// In en, this message translates to: + /// **'Actions'** + String get headlineActionsModalTitle; + + /// Label for the share action. + /// + /// In en, this message translates to: + /// **'Share'** + String get shareActionLabel; + + /// Label for the action to add a headline to bookmarks. + /// + /// In en, this message translates to: + /// **'Bookmark'** + String get bookmarkActionLabel; + + /// Label for the action to remove a headline from bookmarks. + /// + /// In en, this message translates to: + /// **'Remove Bookmark'** + String get removeBookmarkActionLabel; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index d1b0d2e2..e5592db9 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -317,7 +317,7 @@ class AppLocalizationsAr extends AppLocalizations { String get settingsAppearanceTitle => 'المظهر'; @override - String get settingsFeedDisplayTitle => 'عرض الموجز'; + String get settingsFeedDisplayTitle => 'الموجز'; @override String get settingsArticleDisplayTitle => 'عرض المقال'; @@ -1340,4 +1340,31 @@ class AppLocalizationsAr extends AppLocalizations { @override String get breakingNewsPrefix => 'خبر عاجل'; + + @override + String get settingsFeedClickBehaviorLabel => 'فتح الروابط باستخدام'; + + @override + String get settingsFeedClickBehaviorDefault => 'إفتراضي'; + + @override + String get settingsFeedClickBehaviorInApp => 'متصفح داخل التطبيق'; + + @override + String get settingsFeedClickBehaviorSystem => 'متصفح النظام'; + + @override + String get continueToArticleButtonLabel => 'متابعة إلى المقال'; + + @override + String get headlineActionsModalTitle => 'إجراءات'; + + @override + String get shareActionLabel => 'مشاركة'; + + @override + String get bookmarkActionLabel => 'حفظ'; + + @override + String get removeBookmarkActionLabel => 'إزالة من المحفوظات'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a41b4b0c..df1d90bd 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -319,7 +319,7 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsAppearanceTitle => 'Appearance'; @override - String get settingsFeedDisplayTitle => 'Feed Display'; + String get settingsFeedDisplayTitle => 'Feed'; @override String get settingsArticleDisplayTitle => 'Article Display'; @@ -1344,4 +1344,31 @@ class AppLocalizationsEn extends AppLocalizations { @override String get breakingNewsPrefix => 'Breaking News'; + + @override + String get settingsFeedClickBehaviorLabel => 'Open links using'; + + @override + String get settingsFeedClickBehaviorDefault => 'App default'; + + @override + String get settingsFeedClickBehaviorInApp => 'In-app browser'; + + @override + String get settingsFeedClickBehaviorSystem => 'System browser'; + + @override + String get continueToArticleButtonLabel => 'Continue to Article'; + + @override + String get headlineActionsModalTitle => 'Actions'; + + @override + String get shareActionLabel => 'Share'; + + @override + String get bookmarkActionLabel => 'Bookmark'; + + @override + String get removeBookmarkActionLabel => 'Remove Bookmark'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 5c90d0f2..68467cf9 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -396,7 +396,7 @@ "@settingsAppearanceTitle": { "description": "Title for the appearance settings section/page" }, - "settingsFeedDisplayTitle": "عرض الموجز", + "settingsFeedDisplayTitle": "الموجز", "@settingsFeedDisplayTitle": { "description": "Title for the feed display settings section/page" }, @@ -1737,5 +1737,41 @@ "breakingNewsPrefix": "خبر عاجل", "@breakingNewsPrefix": { "description": "Prefix text shown for breaking news headlines." + }, + "settingsFeedClickBehaviorLabel": "فتح الروابط باستخدام", + "@settingsFeedClickBehaviorLabel": { + "description": "Label for the setting that controls how article links are opened." + }, + "settingsFeedClickBehaviorDefault": "إفتراضي", + "@settingsFeedClickBehaviorDefault": { + "description": "Option to use the app's default link-opening behavior." + }, + "settingsFeedClickBehaviorInApp": "متصفح داخل التطبيق", + "@settingsFeedClickBehaviorInApp": { + "description": "Option to open links in a browser within the app." + }, + "settingsFeedClickBehaviorSystem": "متصفح النظام", + "@settingsFeedClickBehaviorSystem": { + "description": "Option to open links in the device's default system browser." + }, + "continueToArticleButtonLabel": "متابعة إلى المقال", + "@continueToArticleButtonLabel": { + "description": "Button text on an interstitial ad to proceed to the article." + }, + "headlineActionsModalTitle": "إجراءات", + "@headlineActionsModalTitle": { + "description": "Title for the modal bottom sheet that shows headline actions like share and bookmark." + }, + "shareActionLabel": "مشاركة", + "@shareActionLabel": { + "description": "Label for the share action." + }, + "bookmarkActionLabel": "حفظ", + "@bookmarkActionLabel": { + "description": "Label for the action to add a headline to bookmarks." + }, + "removeBookmarkActionLabel": "إزالة من المحفوظات", + "@removeBookmarkActionLabel": { + "description": "Label for the action to remove a headline from bookmarks." } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index be00de61..52732df7 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -396,9 +396,9 @@ "@settingsAppearanceTitle": { "description": "Title for the appearance settings section/page" }, - "settingsFeedDisplayTitle": "Feed Display", + "settingsFeedDisplayTitle": "Feed", "@settingsFeedDisplayTitle": { - "description": "Title for the feed display settings section/page" + "description": "Title for the feed settings section/page" }, "settingsArticleDisplayTitle": "Article Display", "@settingsArticleDisplayTitle": { @@ -1737,5 +1737,41 @@ "breakingNewsPrefix": "Breaking News", "@breakingNewsPrefix": { "description": "Prefix text shown for breaking news headlines." + }, + "settingsFeedClickBehaviorLabel": "Open links using", + "@settingsFeedClickBehaviorLabel": { + "description": "Label for the setting that controls how article links are opened." + }, + "settingsFeedClickBehaviorDefault": "App default", + "@settingsFeedClickBehaviorDefault": { + "description": "Option to use the app's default link-opening behavior." + }, + "settingsFeedClickBehaviorInApp": "In-app browser", + "@settingsFeedClickBehaviorInApp": { + "description": "Option to open links in a browser within the app." + }, + "settingsFeedClickBehaviorSystem": "System browser", + "@settingsFeedClickBehaviorSystem": { + "description": "Option to open links in the device's default system browser." + }, + "continueToArticleButtonLabel": "Continue to Article", + "@continueToArticleButtonLabel": { + "description": "Button text on an interstitial ad to proceed to the article." + }, + "headlineActionsModalTitle": "Actions", + "@headlineActionsModalTitle": { + "description": "Title for the modal bottom sheet that shows headline actions like share and bookmark." + }, + "shareActionLabel": "Share", + "@shareActionLabel": { + "description": "Label for the share action." + }, + "bookmarkActionLabel": "Bookmark", + "@bookmarkActionLabel": { + "description": "Label for the action to add a headline to bookmarks." + }, + "removeBookmarkActionLabel": "Remove Bookmark", + "@removeBookmarkActionLabel": { + "description": "Label for the action to remove a headline from bookmarks." } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 5e00c843..da90c692 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,10 +25,10 @@ Future main() async { // Ensure Flutter widgets are initialized before any Firebase operations. WidgetsFlutterBinding.ensureInitialized(); - // Initialize Firebase services only on non-web platforms. + // Initialize Firebase services only on non-web platforms and non-demo env. // Firebase is manually initialized using options from AppConfig, // removing the dependency on the auto-generated firebase_options.dart file. - if (!kIsWeb) { + if (!kIsWeb && !(appEnvironment == AppEnvironment.demo)) { await Firebase.initializeApp( options: FirebaseOptions( apiKey: appConfig.firebaseApiKey, diff --git a/lib/notifications/services/firebase_push_notification_service.dart b/lib/notifications/services/firebase_push_notification_service.dart index 79520039..62256fd6 100644 --- a/lib/notifications/services/firebase_push_notification_service.dart +++ b/lib/notifications/services/firebase_push_notification_service.dart @@ -174,13 +174,20 @@ class FirebasePushNotificationService implements PushNotificationService { /// Converts a Firebase [RemoteMessage] to a generic [PushNotificationPayload]. PushNotificationPayload _toPushNotificationPayload(RemoteMessage message) { + final data = message.data; return PushNotificationPayload( title: message.notification?.title ?? '', - body: message.notification?.body ?? '', + notificationId: data['notificationId'] as String? ?? '', + notificationType: PushNotificationSubscriptionDeliveryType.values.byName( + data['notificationType'] as String? ?? 'breakingOnly', + ), + contentType: ContentType.values.byName( + data['contentType'] as String? ?? 'headline', + ), + contentId: data['contentId'] as String? ?? '', imageUrl: message.notification?.android?.imageUrl ?? message.notification?.apple?.imageUrl, - data: message.data, ); } diff --git a/lib/notifications/services/one_signal_push_notification_service.dart b/lib/notifications/services/one_signal_push_notification_service.dart index 49b7f9ee..0626ecfb 100644 --- a/lib/notifications/services/one_signal_push_notification_service.dart +++ b/lib/notifications/services/one_signal_push_notification_service.dart @@ -191,10 +191,16 @@ class OneSignalPushNotificationService extends PushNotificationService { final data = osNotification.additionalData?.map(MapEntry.new) ?? {}; return PushNotificationPayload( - title: osNotification.title ?? '', - body: osNotification.body ?? '', + title: osNotification.title ?? data['title'] as String? ?? '', + notificationId: data['notificationId'] as String? ?? '', + notificationType: PushNotificationSubscriptionDeliveryType.values.byName( + data['notificationType'] as String? ?? 'breakingOnly', + ), + contentType: ContentType.values.byName( + data['contentType'] as String? ?? 'headline', + ), + contentId: data['contentId'] as String? ?? '', imageUrl: osNotification.bigPicture, - data: data, ); } diff --git a/lib/router/router.dart b/lib/router/router.dart index a8ef3b20..74d85f49 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -1,5 +1,5 @@ import 'package:auth_repository/auth_repository.dart'; -import 'package:core/core.dart' hide AppStatus; +import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -33,9 +33,6 @@ import 'package:flutter_news_app_mobile_client_full_source_code/discover/view/so import 'package:flutter_news_app_mobile_client_full_source_code/entity_details/bloc/entity_details_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/entity_details/view/entity_details_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/feed_decorators/services/feed_decorator_service.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/headline-details/bloc/headline_details_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/headline-details/bloc/similar_headlines_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/headline-details/view/headline_details_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/headlines_feed_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/headlines_filter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/services/feed_cache_service.dart'; @@ -260,8 +257,8 @@ GoRouter createRouter({ return BlocProvider( create: (context) { final settingsBloc = SettingsBloc( - userAppSettingsRepository: context - .read>(), + appSettingsRepository: context + .read>(), inlineAdCacheService: context.read(), ); if (userId != null) { @@ -367,41 +364,6 @@ GoRouter createRouter({ path: Routes.accountSavedHeadlines, name: Routes.accountSavedHeadlinesName, builder: (context, state) => const SavedHeadlinesPage(), - routes: [ - GoRoute( - path: Routes.accountArticleDetails, - name: Routes.accountArticleDetailsName, - builder: (context, state) { - final extra = state.extra; - final headlineFromExtra = extra is Headline ? extra : null; - final headlineIdFromPath = state.pathParameters['id']; - final notificationId = extra is Map - ? extra['notificationId'] as String? - : null; - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => HeadlineDetailsBloc( - headlinesRepository: context - .read>(), - ), - ), - BlocProvider( - create: (context) => SimilarHeadlinesBloc( - headlinesRepository: context - .read>(), - ), - ), - ], - child: HeadlineDetailsPage( - initialHeadline: headlineFromExtra, - headlineId: headlineFromExtra?.id ?? headlineIdFromPath, - notificationId: notificationId, - ), - ); - }, - ), - ], ), ], ), @@ -460,40 +422,6 @@ GoRouter createRouter({ ); }, ), - GoRoute( - path: Routes.globalArticleDetails, - name: Routes.globalArticleDetailsName, - builder: (context, state) { - // The 'extra' can be a Headline object (from feed navigation) or a Map - // (from a push notification deep-link). - final extra = state.extra; - final headlineFromExtra = extra is Headline ? extra : null; - final headlineIdFromPath = state.pathParameters['id']; - final notificationId = extra is Map - ? extra['notificationId'] as String? - : null; - - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => HeadlineDetailsBloc( - headlinesRepository: context.read>(), - ), - ), - BlocProvider( - create: (context) => SimilarHeadlinesBloc( - headlinesRepository: context.read>(), - ), - ), - ], - child: HeadlineDetailsPage( - initialHeadline: headlineFromExtra, - headlineId: headlineFromExtra?.id ?? headlineIdFromPath, - notificationId: notificationId, - ), - ); - }, - ), GoRoute( path: '/multi-select-search', name: Routes.multiSelectSearchName, @@ -559,44 +487,6 @@ GoRouter createRouter({ name: Routes.feedName, builder: (context, state) => const HeadlinesFeedPage(), routes: [ - // Sub-route for article details within the feed context. - GoRoute( - path: 'article/:id', - name: Routes.articleDetailsName, - builder: (context, state) { - final extra = state.extra; - final headlineFromExtra = extra is Headline - ? extra - : null; - final headlineIdFromPath = state.pathParameters['id']; - final notificationId = extra is Map - ? extra['notificationId'] as String? - : null; - - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => HeadlineDetailsBloc( - headlinesRepository: context - .read>(), - ), - ), - BlocProvider( - create: (context) => SimilarHeadlinesBloc( - headlinesRepository: context - .read>(), - ), - ), - ], - child: HeadlineDetailsPage( - initialHeadline: headlineFromExtra, - headlineId: - headlineFromExtra?.id ?? headlineIdFromPath, - notificationId: notificationId, - ), - ); - }, - ), GoRoute( path: Routes.savedHeadlineFilters, name: Routes.savedHeadlineFiltersName, diff --git a/lib/router/routes.dart b/lib/router/routes.dart index 3730950b..4f97a3d6 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -25,8 +25,6 @@ abstract final class Routes { // --- Global, Top-Level Routes --- static const entityDetails = '/entity-details/:type/:id'; static const entityDetailsName = 'entityDetails'; - static const globalArticleDetails = '/article/:id'; - static const globalArticleDetailsName = 'globalArticleDetails'; // --- Account Sub-Routes --- static const accountSavedHeadlines = 'saved-headlines'; @@ -46,7 +44,6 @@ abstract final class Routes { static const multiSelectSearchName = 'multiSelectSearch'; // Feed - static const articleDetailsName = 'articleDetails'; static const feedFilter = 'filter'; static const feedFilterName = 'feedFilter'; static const feedFilterTopics = 'topics'; @@ -65,10 +62,6 @@ abstract final class Routes { static const sourceListFilter = 'filter'; static const discoverSourceListFilterName = 'discoverSourceListFilter'; - // Account - static const accountArticleDetails = 'article/:id'; - static const accountArticleDetailsName = 'accountArticleDetails'; - // Settings static const settingsAppearance = 'appearance'; static const settingsAppearanceName = 'settingsAppearance'; diff --git a/lib/settings/bloc/settings_bloc.dart b/lib/settings/bloc/settings_bloc.dart index 5d73ebdd..1e506e8e 100644 --- a/lib/settings/bloc/settings_bloc.dart +++ b/lib/settings/bloc/settings_bloc.dart @@ -19,9 +19,9 @@ part 'settings_state.dart'; class SettingsBloc extends Bloc { /// {@macro settings_bloc} SettingsBloc({ - required DataRepository userAppSettingsRepository, + required DataRepository appSettingsRepository, required InlineAdCacheService inlineAdCacheService, - }) : _userAppSettingsRepository = userAppSettingsRepository, + }) : _appSettingsRepository = appSettingsRepository, _inlineAdCacheService = inlineAdCacheService, super(const SettingsState()) { // Register event handlers @@ -46,22 +46,26 @@ class SettingsBloc extends Bloc { _onAppFontWeightChanged, transformer: sequential(), ); - on( - _onHeadlineImageStyleChanged, + on( + _onFeedItemImageStyleChanged, + transformer: sequential(), + ); + on( + _onFeedItemClickBehaviorChanged, transformer: sequential(), ); on(_onLanguageChanged, transformer: sequential()); } - final DataRepository _userAppSettingsRepository; + final DataRepository _appSettingsRepository; final InlineAdCacheService _inlineAdCacheService; Future _persistSettings( - UserAppSettings settingsToSave, + AppSettings settingsToSave, Emitter emit, ) async { try { - await _userAppSettingsRepository.update( + await _appSettingsRepository.update( id: settingsToSave.id, item: settingsToSave, userId: settingsToSave.id, @@ -72,7 +76,7 @@ class SettingsBloc extends Bloc { // that uses the ht data in memory impl // as for the api impl, the backend handle // this use case. - await _userAppSettingsRepository.create( + await _appSettingsRepository.create( item: settingsToSave, userId: settingsToSave.id, ); @@ -89,14 +93,14 @@ class SettingsBloc extends Bloc { ) async { emit(state.copyWith(status: SettingsStatus.loading, clearError: true)); try { - final appSettings = await _userAppSettingsRepository.read( + final appSettings = await _appSettingsRepository.read( id: event.userId, userId: event.userId, ); emit( state.copyWith( status: SettingsStatus.success, - userAppSettings: appSettings, + appSettings: appSettings, ), ); } on HttpException { @@ -112,14 +116,14 @@ class SettingsBloc extends Bloc { SettingsAppThemeModeChanged event, Emitter emit, ) async { - if (state.userAppSettings == null) return; + if (state.appSettings == null) return; - final updatedSettings = state.userAppSettings!.copyWith( - displaySettings: state.userAppSettings!.displaySettings.copyWith( + final updatedSettings = state.appSettings!.copyWith( + displaySettings: state.appSettings!.displaySettings.copyWith( baseTheme: event.themeMode, ), ); - emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); + emit(state.copyWith(appSettings: updatedSettings, clearError: true)); await _persistSettings(updatedSettings, emit); } @@ -127,14 +131,14 @@ class SettingsBloc extends Bloc { SettingsAppThemeNameChanged event, Emitter emit, ) async { - if (state.userAppSettings == null) return; + if (state.appSettings == null) return; - final updatedSettings = state.userAppSettings!.copyWith( - displaySettings: state.userAppSettings!.displaySettings.copyWith( + final updatedSettings = state.appSettings!.copyWith( + displaySettings: state.appSettings!.displaySettings.copyWith( accentTheme: event.themeName, ), ); - emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); + emit(state.copyWith(appSettings: updatedSettings, clearError: true)); // When the theme's accent color changes, ads must be reloaded to reflect // the new styling. Clearing the cache ensures that any visible or // soon-to-be-visible ads are fetched again with the updated theme. @@ -146,14 +150,14 @@ class SettingsBloc extends Bloc { SettingsAppFontSizeChanged event, Emitter emit, ) async { - if (state.userAppSettings == null) return; + if (state.appSettings == null) return; - final updatedSettings = state.userAppSettings!.copyWith( - displaySettings: state.userAppSettings!.displaySettings.copyWith( + final updatedSettings = state.appSettings!.copyWith( + displaySettings: state.appSettings!.displaySettings.copyWith( textScaleFactor: event.fontSize, ), ); - emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); + emit(state.copyWith(appSettings: updatedSettings, clearError: true)); await _persistSettings(updatedSettings, emit); } @@ -161,14 +165,14 @@ class SettingsBloc extends Bloc { SettingsAppFontTypeChanged event, Emitter emit, ) async { - if (state.userAppSettings == null) return; + if (state.appSettings == null) return; - final updatedSettings = state.userAppSettings!.copyWith( - displaySettings: state.userAppSettings!.displaySettings.copyWith( + final updatedSettings = state.appSettings!.copyWith( + displaySettings: state.appSettings!.displaySettings.copyWith( fontFamily: event.fontType, ), ); - emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); + emit(state.copyWith(appSettings: updatedSettings, clearError: true)); await _persistSettings(updatedSettings, emit); } @@ -176,29 +180,29 @@ class SettingsBloc extends Bloc { SettingsAppFontWeightChanged event, Emitter emit, ) async { - if (state.userAppSettings == null) return; + if (state.appSettings == null) return; - final updatedSettings = state.userAppSettings!.copyWith( - displaySettings: state.userAppSettings!.displaySettings.copyWith( + final updatedSettings = state.appSettings!.copyWith( + displaySettings: state.appSettings!.displaySettings.copyWith( fontWeight: event.fontWeight, ), ); - emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); + emit(state.copyWith(appSettings: updatedSettings, clearError: true)); await _persistSettings(updatedSettings, emit); } - Future _onHeadlineImageStyleChanged( - SettingsHeadlineImageStyleChanged event, + Future _onFeedItemImageStyleChanged( + SettingsFeedItemImageStyleChanged event, Emitter emit, ) async { - if (state.userAppSettings == null) return; + if (state.appSettings == null) return; - final updatedSettings = state.userAppSettings!.copyWith( - feedPreferences: state.userAppSettings!.feedPreferences.copyWith( - headlineImageStyle: event.imageStyle, + final updatedSettings = state.appSettings!.copyWith( + feedSettings: state.appSettings!.feedSettings.copyWith( + feedItemImageStyle: event.imageStyle, ), ); - emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); + emit(state.copyWith(appSettings: updatedSettings, clearError: true)); // The headline image style directly influences which native ad template // (small or medium) is requested. To ensure the correct ad format is // displayed, the cache must be cleared, forcing a new ad load with the @@ -207,16 +211,32 @@ class SettingsBloc extends Bloc { await _persistSettings(updatedSettings, emit); } + Future _onFeedItemClickBehaviorChanged( + SettingsFeedItemClickBehaviorChanged event, + Emitter emit, + ) async { + if (state.appSettings == null) return; + + final updatedSettings = state.appSettings!.copyWith( + feedSettings: state.appSettings!.feedSettings.copyWith( + feedItemClickBehavior: event.clickBehavior, + ), + ); + emit(state.copyWith(appSettings: updatedSettings, clearError: true)); + // No need to clear ad cache as this setting does not affect ad appearance. + await _persistSettings(updatedSettings, emit); + } + Future _onLanguageChanged( SettingsLanguageChanged event, Emitter emit, ) async { - if (state.userAppSettings == null) return; + if (state.appSettings == null) return; - final updatedSettings = state.userAppSettings!.copyWith( + final updatedSettings = state.appSettings!.copyWith( language: event.language, ); - emit(state.copyWith(userAppSettings: updatedSettings, clearError: true)); + emit(state.copyWith(appSettings: updatedSettings, clearError: true)); await _persistSettings(updatedSettings, emit); } } diff --git a/lib/settings/bloc/settings_event.dart b/lib/settings/bloc/settings_event.dart index 0aa81e57..9a667159 100644 --- a/lib/settings/bloc/settings_event.dart +++ b/lib/settings/bloc/settings_event.dart @@ -102,20 +102,34 @@ class SettingsAppFontWeightChanged extends SettingsEvent { // --- Feed Settings Events --- -/// {@template settings_headline_image_style_changed} +/// {@template settings_feed_item_image_style_changed} /// Event added when the user changes the headline image style in the feed. /// {@endtemplate} -class SettingsHeadlineImageStyleChanged extends SettingsEvent { - /// {@macro settings_headline_image_style_changed} - const SettingsHeadlineImageStyleChanged(this.imageStyle); +class SettingsFeedItemImageStyleChanged extends SettingsEvent { + /// {@macro settings_feed_item_image_style_changed} + const SettingsFeedItemImageStyleChanged(this.imageStyle); /// The newly selected headline image style. - final HeadlineImageStyle imageStyle; + final FeedItemImageStyle imageStyle; @override List get props => [imageStyle]; } +/// {@template settings_feed_item_click_behavior_changed} +/// Event added when the user changes how feed item links are opened. +/// {@endtemplate} +class SettingsFeedItemClickBehaviorChanged extends SettingsEvent { + /// {@macro settings_feed_item_click_behavior_changed} + const SettingsFeedItemClickBehaviorChanged(this.clickBehavior); + + /// The newly selected click behavior. + final FeedItemClickBehavior clickBehavior; + + @override + List get props => [clickBehavior]; +} + /// {@template settings_language_changed} /// Event added when the user changes the application language. /// {@endtemplate} diff --git a/lib/settings/bloc/settings_state.dart b/lib/settings/bloc/settings_state.dart index f4c24671..dafdfd96 100644 --- a/lib/settings/bloc/settings_state.dart +++ b/lib/settings/bloc/settings_state.dart @@ -23,17 +23,15 @@ class SettingsState extends Equatable { /// {@macro settings_state} const SettingsState({ this.status = SettingsStatus.initial, - this.userAppSettings, + this.appSettings, this.error, }); /// The current status of loading/updating settings. final SettingsStatus status; - /// Current user application settings. - /// Null if settings haven't been loaded or if there's no authenticated user - /// context for settings yet. - final UserAppSettings? userAppSettings; + /// Current user application settings. Null if settings haven't been loaded. + final AppSettings? appSettings; /// An optional error object if the status is [SettingsStatus.failure]. final Object? error; @@ -41,20 +39,18 @@ class SettingsState extends Equatable { /// Creates a copy of the current state with updated values. SettingsState copyWith({ SettingsStatus? status, - UserAppSettings? userAppSettings, + AppSettings? appSettings, Object? error, bool clearError = false, - bool clearUserAppSettings = false, + bool clearAppSettings = false, }) { return SettingsState( status: status ?? this.status, - userAppSettings: clearUserAppSettings - ? null - : userAppSettings ?? this.userAppSettings, + appSettings: clearAppSettings ? null : appSettings ?? this.appSettings, error: clearError ? null : error ?? this.error, ); } @override - List get props => [status, userAppSettings, error]; + List get props => [status, appSettings, error]; } diff --git a/lib/settings/view/feed_settings_page.dart b/lib/settings/view/feed_settings_page.dart index 4016e421..207234e8 100644 --- a/lib/settings/view/feed_settings_page.dart +++ b/lib/settings/view/feed_settings_page.dart @@ -1,4 +1,4 @@ -import 'package:core/core.dart' show HeadlineImageStyle; +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/app/bloc/app_bloc.dart'; @@ -15,17 +15,31 @@ class FeedSettingsPage extends StatelessWidget { const FeedSettingsPage({super.key}); // Helper to map HeadlineImageStyle enum to user-friendly strings - String _imageStyleToString(HeadlineImageStyle style, AppLocalizations l10n) { + String _imageStyleToString(FeedItemImageStyle style, AppLocalizations l10n) { switch (style) { - case HeadlineImageStyle.hidden: + case FeedItemImageStyle.hidden: return l10n.settingsFeedTileTypeTextOnly; - case HeadlineImageStyle.smallThumbnail: + case FeedItemImageStyle.smallThumbnail: return l10n.settingsFeedTileTypeImageStart; - case HeadlineImageStyle.largeThumbnail: + case FeedItemImageStyle.largeThumbnail: return l10n.settingsFeedTileTypeImageTop; } } + String _clickBehaviorToString( + FeedItemClickBehavior behavior, + AppLocalizations l10n, + ) { + switch (behavior) { + case FeedItemClickBehavior.defaultBehavior: + return l10n.settingsFeedClickBehaviorDefault; + case FeedItemClickBehavior.internalNavigation: + return l10n.settingsFeedClickBehaviorInApp; + case FeedItemClickBehavior.externalNavigation: + return l10n.settingsFeedClickBehaviorSystem; + } + } + @override Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; @@ -33,7 +47,7 @@ class FeedSettingsPage extends StatelessWidget { final state = settingsBloc.state; // Ensure we have loaded state before building controls - if (state.status != SettingsStatus.success) { + if (state.status != SettingsStatus.success || state.appSettings == null) { return Scaffold( appBar: AppBar(title: Text(l10n.settingsFeedDisplayTitle)), body: const Center(child: CircularProgressIndicator()), @@ -43,7 +57,7 @@ class FeedSettingsPage extends StatelessWidget { return BlocListener( listener: (context, settingsState) { if (settingsState.status == SettingsStatus.success) { - context.read().add(const AppUserAppSettingsRefreshed()); + context.read().add(const AppSettingsRefreshed()); } }, child: Scaffold( @@ -52,16 +66,31 @@ class FeedSettingsPage extends StatelessWidget { padding: const EdgeInsets.all(AppSpacing.lg), children: [ // --- Feed Tile Type --- - _buildDropdownSetting( + _buildDropdownSetting( context: context, title: l10n.settingsFeedTileTypeLabel, - currentValue: - state.userAppSettings!.feedPreferences.headlineImageStyle, - items: HeadlineImageStyle.values, + currentValue: state.appSettings!.feedSettings.feedItemImageStyle, + items: FeedItemImageStyle.values, itemToString: (style) => _imageStyleToString(style, l10n), onChanged: (value) { if (value != null) { - settingsBloc.add(SettingsHeadlineImageStyleChanged(value)); + settingsBloc.add(SettingsFeedItemImageStyleChanged(value)); + } + }, + ), + const SizedBox(height: AppSpacing.lg), + // --- Feed Item Click Behavior --- + _buildDropdownSetting( + context: context, + title: l10n.settingsFeedClickBehaviorLabel, + currentValue: + state.appSettings!.feedSettings.feedItemClickBehavior, + items: FeedItemClickBehavior.values, + itemToString: (behavior) => + _clickBehaviorToString(behavior, l10n), + onChanged: (value) { + if (value != null) { + settingsBloc.add(SettingsFeedItemClickBehaviorChanged(value)); } }, ), diff --git a/lib/settings/view/font_settings_page.dart b/lib/settings/view/font_settings_page.dart index 52e46fa3..98f0a968 100644 --- a/lib/settings/view/font_settings_page.dart +++ b/lib/settings/view/font_settings_page.dart @@ -56,8 +56,7 @@ class FontSettingsPage extends StatelessWidget { final settingsBloc = context.watch(); final state = settingsBloc.state; - if (state.status != SettingsStatus.success || - state.userAppSettings == null) { + if (state.status != SettingsStatus.success || state.appSettings == null) { return Scaffold( appBar: AppBar(title: Text(l10n.settingsAppearanceTitle)), body: const Center(child: CircularProgressIndicator()), @@ -68,7 +67,7 @@ class FontSettingsPage extends StatelessWidget { listener: (context, settingsState) { // Renamed state to avoid conflict if (settingsState.status == SettingsStatus.success) { - context.read().add(const AppUserAppSettingsRefreshed()); + context.read().add(const AppSettingsRefreshed()); } }, child: Scaffold( @@ -80,8 +79,7 @@ class FontSettingsPage extends StatelessWidget { _buildDropdownSetting( context: context, title: l10n.settingsAppearanceAppFontSizeLabel, - currentValue: - state.userAppSettings!.displaySettings.textScaleFactor, + currentValue: state.appSettings!.displaySettings.textScaleFactor, items: AppTextScaleFactor.values, itemToString: (size) => _textScaleFactorToString(size, l10n), onChanged: (value) { @@ -96,7 +94,7 @@ class FontSettingsPage extends StatelessWidget { _buildDropdownSetting( context: context, title: l10n.settingsAppearanceAppFontTypeLabel, - currentValue: state.userAppSettings!.displaySettings.fontFamily, + currentValue: state.appSettings!.displaySettings.fontFamily, items: const [ 'SystemDefault', 'Roboto', @@ -119,7 +117,7 @@ class FontSettingsPage extends StatelessWidget { _buildDropdownSetting( context: context, title: l10n.settingsAppearanceFontWeightLabel, - currentValue: state.userAppSettings!.displaySettings.fontWeight, + currentValue: state.appSettings!.displaySettings.fontWeight, items: AppFontWeight.values, itemToString: (weight) => _fontWeightToString(weight, l10n), onChanged: (value) { diff --git a/lib/settings/view/language_settings_page.dart b/lib/settings/view/language_settings_page.dart index cc06669e..e3daef12 100644 --- a/lib/settings/view/language_settings_page.dart +++ b/lib/settings/view/language_settings_page.dart @@ -20,14 +20,14 @@ class LanguageSettingsPage extends StatelessWidget { final settingsState = settingsBloc.state; if (settingsState.status != SettingsStatus.success || - settingsState.userAppSettings == null) { + settingsState.appSettings == null) { return Scaffold( appBar: AppBar(title: Text(l10n.settingsTitle)), body: const Center(child: CircularProgressIndicator()), ); } - final currentLanguage = settingsState.userAppSettings!.language; + final currentLanguage = settingsState.appSettings!.language; // Filter languagesFixturesData to only include English and Arabic final supportedLanguages = languagesFixturesData @@ -37,7 +37,7 @@ class LanguageSettingsPage extends StatelessWidget { return BlocListener( listener: (context, state) { if (state.status == SettingsStatus.success) { - context.read().add(const AppUserAppSettingsRefreshed()); + context.read().add(const AppSettingsRefreshed()); } }, child: Scaffold( diff --git a/lib/settings/view/theme_settings_page.dart b/lib/settings/view/theme_settings_page.dart index cb4714c4..f2a3d0ef 100644 --- a/lib/settings/view/theme_settings_page.dart +++ b/lib/settings/view/theme_settings_page.dart @@ -45,10 +45,8 @@ class ThemeSettingsPage extends StatelessWidget { final state = settingsBloc.state; // Ensure we have loaded state before building controls - // This page should only be reached if settings are successfully loaded - // by the parent ShellRoute providing SettingsBloc. - if (state.status != SettingsStatus.success || - state.userAppSettings == null) { + // This page should only be reached if settings are successfully loaded. + if (state.status != SettingsStatus.success || state.appSettings == null) { return Scaffold( appBar: AppBar(title: Text(l10n.settingsAppearanceTitle)), body: const Center(child: CircularProgressIndicator()), @@ -63,7 +61,7 @@ class ThemeSettingsPage extends StatelessWidget { // A more robust check might involve comparing previous and current userAppSettings // For now, refreshing on any success after an interaction is reasonable. // Ensure AppBloc is available in context before reading - context.read().add(const AppUserAppSettingsRefreshed()); + context.read().add(const AppSettingsRefreshed()); } // Optionally, show a SnackBar for errors if not handled globally // if (settingsState.status == SettingsStatus.failure && settingsState.error != null) { @@ -81,7 +79,7 @@ class ThemeSettingsPage extends StatelessWidget { _buildDropdownSetting( context: context, title: l10n.settingsAppearanceThemeModeLabel, - currentValue: state.userAppSettings!.displaySettings.baseTheme, + currentValue: state.appSettings!.displaySettings.baseTheme, items: AppBaseTheme.values, itemToString: (mode) => _baseThemeToString(mode, l10n), onChanged: (value) { @@ -96,7 +94,7 @@ class ThemeSettingsPage extends StatelessWidget { _buildDropdownSetting( context: context, title: l10n.settingsAppearanceThemeNameLabel, - currentValue: state.userAppSettings!.displaySettings.accentTheme, + currentValue: state.appSettings!.displaySettings.accentTheme, items: AppAccentTheme.values, itemToString: (name) => _accentThemeToString(name, l10n), onChanged: (value) { diff --git a/lib/shared/services/content_limitation_service.dart b/lib/shared/services/content_limitation_service.dart index b0ea734e..9782de73 100644 --- a/lib/shared/services/content_limitation_service.dart +++ b/lib/shared/services/content_limitation_service.dart @@ -75,13 +75,13 @@ class ContentLimitationService { return LimitationStatus.allowed; } - final limits = remoteConfig.userPreferenceConfig; + final limits = remoteConfig.user.limits; final role = user.appRole; switch (action) { case ContentAction.bookmarkHeadline: final count = preferences.savedHeadlines.length; - final limit = limits.savedHeadlinesLimit[role]; + final limit = limits.savedHeadlines[role]; // If no limit is defined for the role, allow the action. if (limit == null) return LimitationStatus.allowed; @@ -93,7 +93,7 @@ class ContentLimitationService { // Check if the user has reached the limit for saving filters. case ContentAction.saveHeadlineFilter: final count = preferences.savedHeadlineFilters.length; - final limitConfig = limits.savedHeadlineFiltersLimit[role]; + final limitConfig = limits.savedHeadlineFilters[role]; // If no limit config is defined for the role, allow the action. if (limitConfig == null) return LimitationStatus.allowed; @@ -106,7 +106,7 @@ class ContentLimitationService { final count = preferences.savedHeadlineFilters .where((filter) => filter.isPinned) .length; - final limit = limits.savedHeadlineFiltersLimit[role]?.pinned; + final limit = limits.savedHeadlineFilters[role]?.pinned; if (limit == null) return LimitationStatus.allowed; @@ -116,7 +116,7 @@ class ContentLimitationService { case ContentAction.subscribeToHeadlineFilterNotifications: final subscriptionLimits = - limits.savedHeadlineFiltersLimit[role]?.notificationSubscriptions; + limits.savedHeadlineFilters[role]?.notificationSubscriptions; // If no subscription limits are defined for the role, allow the action. if (subscriptionLimits == null) return LimitationStatus.allowed; @@ -155,7 +155,7 @@ class ContentLimitationService { case ContentAction.followTopic: case ContentAction.followSource: case ContentAction.followCountry: - final limit = limits.followedItemsLimit[role]; + final limit = limits.followedItems[role]; // Determine the count for the specific item type being followed. final int count; diff --git a/lib/shared/widgets/feed_core/headline_source_row.dart b/lib/shared/widgets/feed_core/headline_source_row.dart index 326981bb..0ff18294 100644 --- a/lib/shared/widgets/feed_core/headline_source_row.dart +++ b/lib/shared/widgets/feed_core/headline_source_row.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/services/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/router/routes.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/headline_actions_bottom_sheet.dart'; import 'package:go_router/go_router.dart'; import 'package:timeago/timeago.dart' as timeago; import 'package:ui_kit/ui_kit.dart'; @@ -11,7 +12,7 @@ import 'package:ui_kit/ui_kit.dart'; /// {@template headline_source_row} /// A widget to display the source and publish date of a headline. /// {@endtemplate} -class HeadlineSourceRow extends StatelessWidget { +class HeadlineSourceRow extends StatefulWidget { /// {@macro headline_source_row} const HeadlineSourceRow({required this.headline, super.key}); @@ -30,6 +31,11 @@ class HeadlineSourceRow extends StatelessWidget { ); } + @override + State createState() => _HeadlineSourceRowState(); +} + +class _HeadlineSourceRowState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -38,7 +44,7 @@ class HeadlineSourceRow extends StatelessWidget { final currentLocale = context.watch().state.locale; final formattedDate = timeago.format( - headline.createdAt, + widget.headline.createdAt, locale: currentLocale.languageCode, ); @@ -56,7 +62,7 @@ class HeadlineSourceRow extends StatelessWidget { children: [ Expanded( child: InkWell( - onTap: () => _handleEntityTap(context), + onTap: () => widget._handleEntityTap(context), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -66,7 +72,7 @@ class HeadlineSourceRow extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(AppSpacing.xs / 2), child: Image.network( - headline.source.logoUrl, + widget.headline.source.logoUrl, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => Icon( Icons.source_outlined, @@ -79,7 +85,7 @@ class HeadlineSourceRow extends StatelessWidget { const SizedBox(width: AppSpacing.xs), Flexible( child: Text( - headline.source.name, + widget.headline.source.name, style: sourceTextStyle, overflow: TextOverflow.ellipsis, ), @@ -88,7 +94,28 @@ class HeadlineSourceRow extends StatelessWidget { ), ), ), - if (formattedDate.isNotEmpty) Text(formattedDate, style: dateTextStyle), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (formattedDate.isNotEmpty) + Padding( + padding: const EdgeInsets.only(right: AppSpacing.xs), + child: Text(formattedDate, style: dateTextStyle), + ), + // Use InkWell + Icon instead of IconButton to have precise control + // over padding and constraints, avoiding the default minimum + // touch target size that misaligns the row height on native. + InkWell( + customBorder: const CircleBorder(), + onTap: () => showModalBottomSheet( + context: context, + builder: (_) => + HeadlineActionsBottomSheet(headline: widget.headline), + ), + child: const Icon(Icons.more_horiz, size: 20), + ), + ], + ), ], ); } diff --git a/lib/shared/widgets/feed_core/headline_tap_handler.dart b/lib/shared/widgets/feed_core/headline_tap_handler.dart index 6556f5bc..0084df49 100644 --- a/lib/shared/widgets/feed_core/headline_tap_handler.dart +++ b/lib/shared/widgets/feed_core/headline_tap_handler.dart @@ -1,9 +1,13 @@ +import 'dart:async'; + import 'package:core/core.dart'; +import 'package:data_repository/data_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/services/interstitial_ad_manager.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; -import 'package:go_router/go_router.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/shared/widgets/in_app_browser.dart'; +import 'package:url_launcher/url_launcher.dart'; /// {@template headline_tap_handler} /// A utility class for handling headline taps, including interstitial ad @@ -13,10 +17,13 @@ abstract final class HeadlineTapHandler { /// Handles a tap on a [Headline] item. /// /// This method performs two key actions sequentially: - /// 1. Notifies the [InterstitialAdManager] of a potential ad transition and - /// awaits its completion (e.g., the user closing the ad). - /// 2. Navigates to the [Routes.articleDetailsName] page for the given headline, - /// but only after the ad has been handled and if the context is still mounted. + /// 1. Notifies the [InterstitialAdManager] of an external navigation trigger + /// and awaits its completion (e.g., the user closing the ad). + /// 2. Determines the correct link-opening behavior by checking user settings + /// first, then falling back to the remote configuration. + /// 3. Launches the headline's URL using the determined browser behavior, + /// but only after the ad has been handled and if the context is still + /// mounted. /// /// - [context]: The current [BuildContext] to access BLoCs and for navigation. /// - [headline]: The [Headline] item that was tapped. @@ -24,17 +31,112 @@ abstract final class HeadlineTapHandler { BuildContext context, Headline headline, ) async { - // Await for the ad to be shown and dismissed. - await context.read().onPotentialAdTrigger(); + // Notify the ad manager of an external navigation and await ad dismissal. + await context.read().onExternalNavigationTrigger(); // Check if the widget is still in the tree before navigating. if (!context.mounted) return; - // Proceed with navigation after the ad is closed. - await context.pushNamed( - Routes.articleDetailsName, - pathParameters: {'id': headline.id}, - extra: headline, + final appState = context.read().state; + var behavior = appState.settings?.feedSettings.feedItemClickBehavior; + + // If user setting is default, fall back to remote config. + if (behavior == FeedItemClickBehavior.defaultBehavior) { + behavior = appState.remoteConfig?.features.feed.itemClickBehavior; + } + + // Use the new InAppBrowser for internal navigation, otherwise use url_launcher. + if (behavior == FeedItemClickBehavior.internalNavigation) { + await InAppBrowser.show(context, url: headline.url); + } else { + if (await canLaunchUrl(Uri.parse(headline.url))) { + await launchUrl( + Uri.parse(headline.url), + mode: LaunchMode.externalApplication, + ); + } + } + } + + /// Handles a tap on a headline when only the ID is available. + /// + /// This method is used for scenarios like tapping a push notification where + /// the full [Headline] object is not readily available. It performs the + /// following steps: + /// 1. Shows a temporary loading indicator. + /// 2. Fetches the full [Headline] object from the repository using the ID. + /// 3. Hides the loading indicator. + /// 4. Delegates to the [handleHeadlineTap] method to perform the standard + /// ad trigger and URL launching logic. + /// + /// - [context]: The current [BuildContext] to access BLoCs and for navigation. + /// - [headlineId]: The ID of the [Headline] item that was tapped. + static Future handleHeadlineTapById( + BuildContext context, + String headlineId, + ) async { + // Show a loading dialog that is resilient to context changes. + final navigator = Navigator.of(context); + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ), + ); + + try { + final headline = await context.read>().read( + id: headlineId, + ); + if (!context.mounted) return; + await handleHeadlineTap(context, headline); + } finally { + if (navigator.canPop()) navigator.pop(); + } + } + + /// Handles a tap on a headline from a system notification. + /// + /// This method is specifically for taps that originate from the OS + /// notification tray. It fetches the headline by its ID and then **always** + /// opens it in an in-app browser, overriding any user or remote settings. + /// This provides a smoother, more integrated user experience for notification + /// interactions. + /// + /// - [context]: The current [BuildContext] to access BLoCs and for navigation. + /// - [headlineId]: The ID of the [Headline] item that was tapped. + /// - [notificationId]: The optional ID of the notification itself, used to + /// mark it as read. + static Future handleTapFromSystemNotification( + BuildContext context, + String headlineId, { + String? notificationId, + }) async { + // If a notificationId is provided, dispatch an event to mark it as read. + if (notificationId != null) { + context.read().add(AppNotificationTapped(notificationId)); + } + + // Show a loading dialog that is resilient to context changes. + final navigator = Navigator.of(context); + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ), ); + + try { + final headline = await context.read>().read( + id: headlineId, + ); + if (context.mounted) { + await InAppBrowser.show(context, url: headline.url); + } + } finally { + if (navigator.canPop()) navigator.pop(); + } } } diff --git a/lib/shared/widgets/feed_core/headline_tile_image_start.dart b/lib/shared/widgets/feed_core/headline_tile_image_start.dart index d07450bf..f65f70f1 100644 --- a/lib/shared/widgets/feed_core/headline_tile_image_start.dart +++ b/lib/shared/widgets/feed_core/headline_tile_image_start.dart @@ -14,7 +14,6 @@ class HeadlineTileImageStart extends StatelessWidget { required this.headline, super.key, this.onHeadlineTap, - this.trailing, this.currentContextEntityType, this.currentContextEntityId, }); @@ -25,9 +24,6 @@ class HeadlineTileImageStart extends StatelessWidget { /// Callback when the main content of the headline (e.g., title area) is tapped. final VoidCallback? onHeadlineTap; - /// An optional widget to display at the end of the tile. - final Widget? trailing; - /// The type of the entity currently being viewed in detail (e.g., on a category page). final ContentType? currentContextEntityType; @@ -113,10 +109,6 @@ class HeadlineTileImageStart extends StatelessWidget { ], ), ), - if (trailing != null) ...[ - const SizedBox(width: AppSpacing.sm), - trailing!, - ], ], ), ), diff --git a/lib/shared/widgets/feed_core/headline_tile_image_top.dart b/lib/shared/widgets/feed_core/headline_tile_image_top.dart index ce8b2adb..45a47b15 100644 --- a/lib/shared/widgets/feed_core/headline_tile_image_top.dart +++ b/lib/shared/widgets/feed_core/headline_tile_image_top.dart @@ -14,7 +14,6 @@ class HeadlineTileImageTop extends StatelessWidget { required this.headline, super.key, this.onHeadlineTap, - this.trailing, this.currentContextEntityType, this.currentContextEntityId, }); @@ -25,9 +24,6 @@ class HeadlineTileImageTop extends StatelessWidget { /// Callback when the main content of the headline (e.g., title area) is tapped. final VoidCallback? onHeadlineTap; - /// An optional widget to display at the end of the tile (e.g., in line with title). - final Widget? trailing; - /// The type of the entity currently being viewed in detail (e.g., on a category page). final ContentType? currentContextEntityType; @@ -130,10 +126,6 @@ class HeadlineTileImageTop extends StatelessWidget { ), ), ), - if (trailing != null) ...[ - const SizedBox(width: AppSpacing.sm), - trailing!, - ], ], ), ), diff --git a/lib/shared/widgets/feed_core/headline_tile_text_only.dart b/lib/shared/widgets/feed_core/headline_tile_text_only.dart index 33c67e81..38c033a3 100644 --- a/lib/shared/widgets/feed_core/headline_tile_text_only.dart +++ b/lib/shared/widgets/feed_core/headline_tile_text_only.dart @@ -16,7 +16,6 @@ class HeadlineTileTextOnly extends StatelessWidget { required this.headline, super.key, this.onHeadlineTap, - this.trailing, this.currentContextEntityType, this.currentContextEntityId, }); @@ -27,9 +26,6 @@ class HeadlineTileTextOnly extends StatelessWidget { /// Callback when the main content of the headline (e.g., title) is tapped. final VoidCallback? onHeadlineTap; - /// An optional widget to display at the end of the tile. - final Widget? trailing; - /// The type of the entity currently being viewed in detail (e.g., on a category page). final ContentType? currentContextEntityType; @@ -86,10 +82,6 @@ class HeadlineTileTextOnly extends StatelessWidget { ], ), ), - if (trailing != null) ...[ - const SizedBox(width: AppSpacing.sm), - trailing!, - ], ], ), ), diff --git a/lib/shared/widgets/headline_actions_bottom_sheet.dart b/lib/shared/widgets/headline_actions_bottom_sheet.dart new file mode 100644 index 00000000..29021516 --- /dev/null +++ b/lib/shared/widgets/headline_actions_bottom_sheet.dart @@ -0,0 +1,88 @@ +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/app/bloc/app_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template headline_actions_bottom_sheet} +/// A modal bottom sheet that displays actions for a given headline, such as +/// sharing and bookmarking. +/// {@endtemplate} +class HeadlineActionsBottomSheet extends StatelessWidget { + /// {@macro headline_actions_bottom_sheet} + const HeadlineActionsBottomSheet({required this.headline, super.key}); + + /// The headline for which to display actions. + final Headline headline; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + return BlocBuilder( + builder: (context, state) { + final isBookmarked = + state.userContentPreferences?.savedHeadlines.any( + (saved) => saved.id == headline.id, + ) ?? + false; + + return Wrap( + children: [ + Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Text( + l10n.headlineActionsModalTitle, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ListTile( + leading: const Icon(Icons.share_outlined), + title: Text(l10n.shareActionLabel), + onTap: () { + Navigator.of(context).pop(); + Share.share(headline.url); + }, + ), + ListTile( + leading: Icon( + isBookmarked + ? Icons.bookmark_added + : Icons.bookmark_add_outlined, + ), + title: Text( + isBookmarked + ? l10n.removeBookmarkActionLabel + : l10n.bookmarkActionLabel, + ), + onTap: () { + final userContentPreferences = state.userContentPreferences; + if (userContentPreferences == null) return; + + final currentSaved = List.from( + userContentPreferences.savedHeadlines, + ); + + if (isBookmarked) { + currentSaved.removeWhere((h) => h.id == headline.id); + } else { + currentSaved.insert(0, headline); + } + + context.read().add( + AppUserContentPreferencesChanged( + preferences: userContentPreferences.copyWith( + savedHeadlines: currentSaved, + ), + ), + ); + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } +} diff --git a/lib/shared/widgets/in_app_browser.dart b/lib/shared/widgets/in_app_browser.dart new file mode 100644 index 00000000..5e17192c --- /dev/null +++ b/lib/shared/widgets/in_app_browser.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; + +/// {@template in_app_browser} +/// A modal widget that displays a web page within the app using a custom +/// InAppWebView implementation. +/// +/// This browser is presented modally and includes a custom app bar with a +/// close button, providing a consistent and controlled browsing experience. +/// {@endtemplate} +class InAppBrowser extends StatefulWidget { + /// {@macro in_app_browser} + const InAppBrowser({required this.url, super.key}); + + /// The initial URL to load in the web view. + final String url; + + /// A static method to show the browser as a modal dialog. + static Future show(BuildContext context, {required String url}) { + return showGeneralDialog( + context: context, + barrierDismissible: false, + pageBuilder: (context, animation, secondaryAnimation) => + InAppBrowser(url: url), + ); + } + + @override + State createState() => _InAppBrowserState(); +} + +class _InAppBrowserState extends State { + double _progress = 0; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + backgroundColor: theme.colorScheme.surface, + elevation: 0, + bottom: _progress < 1.0 + ? PreferredSize( + preferredSize: const Size.fromHeight(2), + child: LinearProgressIndicator( + value: _progress, + backgroundColor: Colors.transparent, + ), + ) + : null, + ), + body: InAppWebView( + initialUrlRequest: URLRequest(url: WebUri(widget.url)), + initialSettings: InAppWebViewSettings( + // Restrict navigation to the initial domain to keep the user focused. + useShouldOverrideUrlLoading: true, + ), + onProgressChanged: (controller, progress) { + setState(() { + _progress = progress / 100; + }); + }, + shouldOverrideUrlLoading: (controller, navigationAction) async { + // Allow the initial URL to load, but cancel any subsequent navigations. + return navigationAction.isForMainFrame + ? NavigationActionPolicy.CANCEL + : NavigationActionPolicy.ALLOW; + }, + ), + ); + } +} diff --git a/lib/shared/widgets/notification_indicator.dart b/lib/shared/widgets/notification_indicator.dart index fd430e40..bcf83bfd 100644 --- a/lib/shared/widgets/notification_indicator.dart +++ b/lib/shared/widgets/notification_indicator.dart @@ -27,9 +27,10 @@ class NotificationIndicator extends StatelessWidget { children: [ child, if (showIndicator) - Positioned( - top: 0, - right: 0, + Positioned.directional( + textDirection: Directionality.of(context), + top: 2, + end: 2, child: Container( width: AppSpacing.sm, height: AppSpacing.sm, diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 63d20e4c..cd2873fb 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -1,6 +1,7 @@ export 'confirmation_dialog.dart'; export 'content_limitation_bottom_sheet.dart'; export 'feed_core/feed_core.dart'; +export 'headline_actions_bottom_sheet.dart'; export 'multi_select_search_page.dart'; export 'notification_indicator.dart'; export 'user_avatar.dart'; diff --git a/pubspec.lock b/pubspec.lock index 611c594c..4399bbd4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -185,8 +185,8 @@ packages: dependency: "direct main" description: path: "." - ref: "643aa3830261d04806569defd013639c92eaf5a3" - resolved-ref: "643aa3830261d04806569defd013639c92eaf5a3" + ref: "3779a8b1dbd8450d524574cf5376b7cc2ed514e7" + resolved-ref: "3779a8b1dbd8450d524574cf5376b7cc2ed514e7" url: "https://github.com/flutter-news-app-full-source-code/core.git" source: git version: "1.3.1" @@ -423,6 +423,70 @@ packages: url: "https://pub.dev" source: hosted version: "9.1.1" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 + url: "https://pub.dev" + source: hosted + version: "1.3.0+1" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_windows: + dependency: transitive + description: + name: flutter_inappwebview_windows + sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055" + url: "https://pub.dev" + source: hosted + version: "0.6.0" flutter_launcher_icons: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 8c185ba5..6838ba66 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,6 +72,7 @@ dependencies: sdk: flutter flutter_adaptive_scaffold: ^0.3.2 flutter_bloc: ^9.1.1 + flutter_inappwebview: 6.1.5 flutter_launcher_icons: ^0.14.4 flutter_localizations: sdk: flutter @@ -145,7 +146,7 @@ dependency_overrides: core: git: url: https://github.com/flutter-news-app-full-source-code/core.git - ref: 643aa3830261d04806569defd013639c92eaf5a3 + ref: 3779a8b1dbd8450d524574cf5376b7cc2ed514e7 http_client: git: url: https://github.com/flutter-news-app-full-source-code/http-client.git