From 1a7d1e0c3c2fb13e75c367c1f7427dd2997fc7cf Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 7 Oct 2025 08:38:40 +0100 Subject: [PATCH 01/13] feat(shared): add content limitation service - Implement ContentLimitationService to centralize content limitation logic - Define ContentAction and LimitationStatus enums for better readability - Add checkAction method to determine if a user can perform a content-related action - Implement role-based limitation handling - Ensure service fails open in case of incomplete data to prevent unnecessary blocking --- .../services/content_limitation_service.dart | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 lib/shared/services/content_limitation_service.dart diff --git a/lib/shared/services/content_limitation_service.dart b/lib/shared/services/content_limitation_service.dart new file mode 100644 index 00000000..0d49ecdf --- /dev/null +++ b/lib/shared/services/content_limitation_service.dart @@ -0,0 +1,118 @@ +import 'package:core/core.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; + + +/// Defines the specific type of content-related action a user is trying to +/// perform, which may be subject to limitations. +enum ContentAction { + /// The action of bookmarking a headline. + bookmarkHeadline, + + /// The action of following a topic. + followTopic, + + /// The action of following a source. + followSource, + + /// The action of following a country. + followCountry, +} + +/// Defines the outcome of a content limitation check. +enum LimitationStatus { + /// The user is permitted to perform the action. + allowed, + + /// The user has reached the content limit for anonymous (guest) users. + anonymousLimitReached, + + /// The user has reached the content limit for standard (free) users. + standardUserLimitReached, + + /// The user has reached the content limit for premium users. + premiumUserLimitReached, +} + + +/// {@template content_limitation_service} +/// A service that centralizes the logic for checking if a user can perform +/// a content-related action based on their role and remote configuration limits. +/// +/// This service acts as the single source of truth for content limitations, +/// ensuring that rules for actions like bookmarking or following are applied +/// consistently throughout the application. +/// {@endtemplate} +class ContentLimitationService { + /// {@macro content_limitation_service} + const ContentLimitationService({required AppBloc appBloc}) + : _appBloc = appBloc; + + final AppBloc _appBloc; + + /// Checks if the current user is allowed to perform a given [action]. + /// + /// Returns a [LimitationStatus] indicating whether the action is allowed or + /// if a specific limit has been reached. + LimitationStatus checkAction(ContentAction action) { + final state = _appBloc.state; + final user = state.user; + final preferences = state.userContentPreferences; + final remoteConfig = state.remoteConfig; + + // Fail open: If essential data is missing, allow the action to prevent + // blocking users due to an incomplete app state. + if (user == null || preferences == null || remoteConfig == null) { + return LimitationStatus.allowed; + } + + final limits = remoteConfig.contentConfiguration; + final role = user.appRole; + + switch (action) { + case ContentAction.bookmarkHeadline: + final count = preferences.savedHeadlines.length; + final limit = limits.savedHeadlinesLimit[role]; + if (limit != null && count >= limit) { + return _getLimitationStatusForRole(role); + } + + case ContentAction.followTopic: + final count = preferences.followedTopics.length; + final limit = limits.followedTopicsLimit[role]; + if (limit != null && count >= limit) { + return _getLimitationStatusForRole(role); + } + + case ContentAction.followSource: + final count = preferences.followedSources.length; + final limit = limits.followedSourcesLimit[role]; + if (limit != null && count >= limit) { + return _getLimitationStatusForRole(role); + } + + case ContentAction.followCountry: + final count = preferences.followedCountries.length; + final limit = limits.followedCountriesLimit[role]; + if (limit != null && count >= limit) { + return _getLimitationStatusForRole(role); + } + } + + // If no limit was hit, the action is allowed. + return LimitationStatus.allowed; + } + + /// Maps an [AppUserRole] to the corresponding [LimitationStatus]. + /// + /// This helper function ensures a consistent mapping when a limit is reached. + LimitationStatus _getLimitationStatusForRole(AppUserRole role) { + switch (role) { + case AppUserRole.guestUser: + return LimitationStatus.anonymousLimitReached; + case AppUserRole.standardUser: + return LimitationStatus.standardUserLimitReached; + case AppUserRole.premiumUser: + return LimitationStatus.premiumUserLimitReached; + } + } +} From 93a84494cf285419652b1b6045166d50f01f5ce5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 7 Oct 2025 08:38:51 +0100 Subject: [PATCH 02/13] feat(shared): add ContentLimitationService - Add ContentLimitationService to the app's dependency injection system - Ensure ContentLimitationService is created with the required AppBloc dependency - Update imports to include the new service --- lib/app/view/app.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index d21eb6ca..00ab3639 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -16,6 +16,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/authentication/b import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/router.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/status/view/view.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; import 'package:go_router/go_router.dart'; import 'package:kv_storage_service/kv_storage_service.dart'; import 'package:logging/logging.dart'; @@ -158,6 +159,12 @@ class App extends StatelessWidget { // Ensure it's created immediately lazy: false, ), + // Provide the ContentLimitationService. + // It depends on AppBloc, so it is created here. + RepositoryProvider( + create: (context) => + ContentLimitationService(appBloc: context.read()), + ), ], child: _AppView( authenticationRepository: _authenticationRepository, From 3c1ec73e2f602166bfca9b53f3556a3cc3d16614 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 7 Oct 2025 08:41:25 +0100 Subject: [PATCH 03/13] feat(shared): add content limitation bottom sheet - Implement ContentLimitationBottomSheet widget to handle different user limitation statuses - Add private widgets for anonymous, standard, and premium user limit views - Include navigation to account linking and manage followed items pages - Implement localization placeholders for titles, bodies, and buttons - Create _BaseLimitView for shared layout among different limit views --- .../content_limitation_bottom_sheet.dart | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 lib/shared/widgets/content_limitation_bottom_sheet.dart diff --git a/lib/shared/widgets/content_limitation_bottom_sheet.dart b/lib/shared/widgets/content_limitation_bottom_sheet.dart new file mode 100644 index 00000000..bc27d2d9 --- /dev/null +++ b/lib/shared/widgets/content_limitation_bottom_sheet.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.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:go_router/go_router.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template content_limitation_bottom_sheet} +/// A bottom sheet that informs the user about content limitations and provides +/// relevant actions based on their status. +/// {@endtemplate} +class ContentLimitationBottomSheet extends StatelessWidget { + /// {@macro content_limitation_bottom_sheet} + const ContentLimitationBottomSheet({required this.status, super.key}); + + /// The limitation status that determines the content of the bottom sheet. + final LimitationStatus status; + + @override + Widget build(BuildContext context) { + // Use a switch to build the appropriate view based on the status. + // Each case returns a dedicated private widget for clarity. + switch (status) { + case LimitationStatus.anonymousLimitReached: + return const _AnonymousLimitView(); + case LimitationStatus.standardUserLimitReached: + return const _StandardUserLimitView(); + case LimitationStatus.premiumUserLimitReached: + return const _PremiumUserLimitView(); + case LimitationStatus.allowed: + // If the action is allowed, no UI is needed. + return const SizedBox.shrink(); + } + } +} + +/// A private widget to show when an anonymous user hits a limit. +class _AnonymousLimitView extends StatelessWidget { + const _AnonymousLimitView(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + + return _BaseLimitView( + icon: Icons.person_add_alt_1_outlined, + // TODO(fulleni): Add l10n.anonymousLimitTitle + title: 'Sign in to Save More', + // TODO(fulleni): Add l10n.anonymousLimitBody + body: + 'Create a free account to save and follow unlimited topics, sources, and countries.', + child: ElevatedButton( + onPressed: () { + // Pop the bottom sheet first. + Navigator.of(context).pop(); + // Then navigate to the account linking page. + context.pushNamed(Routes.accountLinkingName); + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(AppSpacing.xxl + AppSpacing.sm), + ), + // TODO(fulleni): Add l10n.anonymousLimitButton + child: const Text('Sign In & Link Account'), + ), + ); + } +} + +/// A private widget to show when a standard (free) user hits a limit. +class _StandardUserLimitView extends StatelessWidget { + const _StandardUserLimitView(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + + return _BaseLimitView( + icon: Icons.workspace_premium_outlined, + // TODO(fulleni): Add l10n.standardLimitTitle + title: 'Unlock Unlimited Access', + // TODO(fulleni): Add l10n.standardLimitBody + body: + "You've reached your limit for the free plan. Upgrade to save and follow more.", + child: ElevatedButton( + // TODO(fulleni): Implement account upgrade flow. + // The upgrade flow is not yet implemented, so the button is disabled. + onPressed: null, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(AppSpacing.xxl + AppSpacing.sm), + ), + // TODO(fulleni): Add l10n.standardLimitButton + child: const Text('Upgrade to Premium'), + ), + ); + } +} + +/// A private widget to show when a premium user hits a limit. +class _PremiumUserLimitView extends StatelessWidget { + const _PremiumUserLimitView(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + + return _BaseLimitView( + icon: Icons.inventory_2_outlined, + // TODO(fulleni): Add l10n.premiumLimitTitle + title: "You've Reached the Limit", + // TODO(fulleni): Add l10n.premiumLimitBody + body: + 'To add new items, please review and manage your existing saved and followed content.', + child: ElevatedButton( + onPressed: () { + // Pop the bottom sheet first. + Navigator.of(context).pop(); + // Then navigate to the page for managing followed items. + context.goNamed(Routes.manageFollowedItemsName); + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(AppSpacing.xxl + AppSpacing.sm), + ), + // TODO(fulleni): Add l10n.premiumLimitButton + child: const Text('Manage My Content'), + ), + ); + } +} + +/// A base layout for the content limitation views to reduce duplication. +class _BaseLimitView extends StatelessWidget { + const _BaseLimitView({ + required this.icon, + required this.title, + required this.body, + required this.child, + }); + + final IconData icon; + final String title; + final String body; + final Widget child; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(AppSpacing.paddingLarge), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: AppSpacing.xxl * 1.5, color: Colors.blue), + const SizedBox(height: AppSpacing.lg), + Text( + title, + style: const TextStyle(fontSize: 22), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.md), + Text( + body, + style: const TextStyle(fontSize: 16), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + child, + ], + ), + ); + } +} From f541d0a17a01e28a52f910565504688c68ac7026 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 7 Oct 2025 08:43:49 +0100 Subject: [PATCH 04/13] feat(headline-details): implement content limitation for bookmarking headlines - Add check for maximum saved headlines before allowing a new save - Show content limitation bottom sheet when save limit is reached - Always allow un-saving headlines without limitation check - Import necessary services and widgets for content limitation functionality --- .../view/headline_details_page.dart | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/lib/headline-details/view/headline_details_page.dart b/lib/headline-details/view/headline_details_page.dart index 5f99c16b..42509172 100644 --- a/lib/headline-details/view/headline_details_page.dart +++ b/lib/headline-details/view/headline_details_page.dart @@ -13,6 +13,8 @@ import 'package:flutter_news_app_mobile_client_full_source_code/headline-details 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'; @@ -198,23 +200,42 @@ class _HeadlineDetailsPageState extends State { return; } - final List updatedSavedHeadlines; + // If the user is un-saving, always allow it. if (isSaved) { - updatedSavedHeadlines = currentPreferences.savedHeadlines + final updatedSavedHeadlines = currentPreferences.savedHeadlines .where((h) => h.id != headline.id) .toList(); + final updatedPreferences = currentPreferences.copyWith( + savedHeadlines: updatedSavedHeadlines, + ); + context.read().add( + AppUserContentPreferencesChanged(preferences: updatedPreferences), + ); } else { - updatedSavedHeadlines = List.from(currentPreferences.savedHeadlines) - ..add(headline); - } - - final updatedPreferences = currentPreferences.copyWith( - savedHeadlines: updatedSavedHeadlines, - ); + // If the user is saving, check the limit first. + final limitationService = context.read(); + final status = limitationService.checkAction( + ContentAction.bookmarkHeadline, + ); - context.read().add( - AppUserContentPreferencesChanged(preferences: updatedPreferences), - ); + 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), + ); + } + } }, ); From 914f333fb149c969dcda5a99a3692da1c833c27c Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 7 Oct 2025 08:46:17 +0100 Subject: [PATCH 05/13] feat(entity_details): implement content limitation check for follow actions - Add ContentLimitationService import and usage in EntityDetailsView - Implement logic to check content limitation before allowing follow actions - Display ContentLimitationBottomSheet when action is limited - Update follow button onPressed logic to handle both following and unfollowing cases --- .../view/entity_details_page.dart | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index e5087765..0e32527f 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -12,6 +12,8 @@ import 'package:flutter_news_app_mobile_client_full_source_code/entity_details/b 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'; @@ -165,9 +167,45 @@ class _EntityDetailsViewState extends State { ? l10n.unfollowButtonLabel : l10n.followButtonLabel, onPressed: () { - context.read().add( - const EntityDetailsToggleFollowRequested(), - ); + // If the user is unfollowing, always allow it. + if (state.isFollowing) { + context.read().add( + const EntityDetailsToggleFollowRequested(), + ); + } else { + // If the user is following, check the limit first. + final limitationService = context + .read(); + final contentType = state.contentType; + + if (contentType == null) return; + + final ContentAction action; + switch (contentType) { + case ContentType.topic: + action = ContentAction.followTopic; + case ContentType.source: + action = ContentAction.followSource; + case ContentType.country: + action = ContentAction.followCountry; + case ContentType.headline: + return; + } + + final status = limitationService.checkAction(action); + + if (status == LimitationStatus.allowed) { + context.read().add( + const EntityDetailsToggleFollowRequested(), + ); + } else { + showModalBottomSheet( + context: context, + builder: (_) => + ContentLimitationBottomSheet(status: status), + ); + } + } }, ); From e8a85a9e9a0aa40f7cb5a9e8927c508e11f792c8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 7 Oct 2025 08:50:39 +0100 Subject: [PATCH 06/13] feat(l10n): add localization strings for content limit bottom sheets - Add Arabic and English translations for bottom sheet content when users hit content limits - Include titles, body text, and button labels for anonymous, standard, and premium user scenarios --- lib/l10n/app_localizations.dart | 54 ++++++++++++++++++++++++++++++ lib/l10n/app_localizations_ar.dart | 30 +++++++++++++++++ lib/l10n/app_localizations_en.dart | 30 +++++++++++++++++ lib/l10n/arb/app_ar.arb | 36 ++++++++++++++++++++ lib/l10n/arb/app_en.arb | 36 ++++++++++++++++++++ 5 files changed, 186 insertions(+) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index cdabc1e3..d99d3585 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1723,6 +1723,60 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Required version: {version}'** String latestRequiredVersionLabel(String version); + + /// Title for the bottom sheet when an anonymous user hits a content limit. + /// + /// In en, this message translates to: + /// **'Sign in to Save More'** + String get anonymousLimitTitle; + + /// Body text for the bottom sheet when an anonymous user hits a content limit. + /// + /// In en, this message translates to: + /// **'Create a free account to save and follow unlimited topics, sources, and countries.'** + String get anonymousLimitBody; + + /// Button text for the bottom sheet when an anonymous user hits a content limit. + /// + /// In en, this message translates to: + /// **'Sign In & Link Account'** + String get anonymousLimitButton; + + /// Title for the bottom sheet when a standard user hits a content limit. + /// + /// In en, this message translates to: + /// **'Unlock Unlimited Access'** + String get standardLimitTitle; + + /// Body text for the bottom sheet when a standard user hits a content limit. + /// + /// In en, this message translates to: + /// **'You\'ve reached your limit for the free plan. Upgrade to save and follow more.'** + String get standardLimitBody; + + /// Button text for the bottom sheet when a standard user hits a content limit. + /// + /// In en, this message translates to: + /// **'Upgrade to Premium'** + String get standardLimitButton; + + /// Title for the bottom sheet when a premium user hits a content limit. + /// + /// In en, this message translates to: + /// **'You\'ve Reached the Limit'** + String get premiumLimitTitle; + + /// Body text for the bottom sheet when a premium user hits a content limit. + /// + /// In en, this message translates to: + /// **'To add new items, please review and manage your existing saved and followed content.'** + String get premiumLimitBody; + + /// Button text for the bottom sheet when a premium user hits a content limit. + /// + /// In en, this message translates to: + /// **'Manage My Content'** + String get premiumLimitButton; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 27498d30..aa9a40f6 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -904,4 +904,34 @@ class AppLocalizationsAr extends AppLocalizations { String latestRequiredVersionLabel(String version) { return 'الإصدار المطلوب: $version'; } + + @override + String get anonymousLimitTitle => 'سجّل الدخول لحفظ المزيد'; + + @override + String get anonymousLimitBody => + 'أنشئ حسابًا مجانيًا لحفظ ومتابعة عدد غير محدود من المواضيع والمصادر والدول.'; + + @override + String get anonymousLimitButton => 'تسجيل الدخول وربط الحساب'; + + @override + String get standardLimitTitle => 'افتح الوصول غير المحدود'; + + @override + String get standardLimitBody => + 'لقد وصلت إلى الحد الأقصى للباقة المجانية. قم بالترقية لحفظ ومتابعة المزيد.'; + + @override + String get standardLimitButton => 'الترقية إلى بريميوم'; + + @override + String get premiumLimitTitle => 'لقد وصلت إلى الحد الأقصى'; + + @override + String get premiumLimitBody => + 'لإضافة عناصر جديدة، يرجى مراجعة وإدارة المحتوى المحفوظ والمتابع الحالي.'; + + @override + String get premiumLimitButton => 'إدارة المحتوى الخاص بي'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 87b98607..68afbe61 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -905,4 +905,34 @@ class AppLocalizationsEn extends AppLocalizations { String latestRequiredVersionLabel(String version) { return 'Required version: $version'; } + + @override + String get anonymousLimitTitle => 'Sign in to Save More'; + + @override + String get anonymousLimitBody => + 'Create a free account to save and follow unlimited topics, sources, and countries.'; + + @override + String get anonymousLimitButton => 'Sign In & Link Account'; + + @override + String get standardLimitTitle => 'Unlock Unlimited Access'; + + @override + String get standardLimitBody => + 'You\'ve reached your limit for the free plan. Upgrade to save and follow more.'; + + @override + String get standardLimitButton => 'Upgrade to Premium'; + + @override + String get premiumLimitTitle => 'You\'ve Reached the Limit'; + + @override + String get premiumLimitBody => + 'To add new items, please review and manage your existing saved and followed content.'; + + @override + String get premiumLimitButton => 'Manage My Content'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 8c1a74b1..03c936cd 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1189,5 +1189,41 @@ "example": "1.1.0" } } + }, + "anonymousLimitTitle": "سجّل الدخول لحفظ المزيد", + "@anonymousLimitTitle": { + "description": "Title for the bottom sheet when an anonymous user hits a content limit." + }, + "anonymousLimitBody": "أنشئ حسابًا مجانيًا لحفظ ومتابعة عدد غير محدود من المواضيع والمصادر والدول.", + "@anonymousLimitBody": { + "description": "Body text for the bottom sheet when an anonymous user hits a content limit." + }, + "anonymousLimitButton": "تسجيل الدخول وربط الحساب", + "@anonymousLimitButton": { + "description": "Button text for the bottom sheet when an anonymous user hits a content limit." + }, + "standardLimitTitle": "افتح الوصول غير المحدود", + "@standardLimitTitle": { + "description": "Title for the bottom sheet when a standard user hits a content limit." + }, + "standardLimitBody": "لقد وصلت إلى الحد الأقصى للباقة المجانية. قم بالترقية لحفظ ومتابعة المزيد.", + "@standardLimitBody": { + "description": "Body text for the bottom sheet when a standard user hits a content limit." + }, + "standardLimitButton": "الترقية إلى بريميوم", + "@standardLimitButton": { + "description": "Button text for the bottom sheet when a standard user hits a content limit." + }, + "premiumLimitTitle": "لقد وصلت إلى الحد الأقصى", + "@premiumLimitTitle": { + "description": "Title for the bottom sheet when a premium user hits a content limit." + }, + "premiumLimitBody": "لإضافة عناصر جديدة، يرجى مراجعة وإدارة المحتوى المحفوظ والمتابع الحالي.", + "@premiumLimitBody": { + "description": "Body text for the bottom sheet when a premium user hits a content limit." + }, + "premiumLimitButton": "إدارة المحتوى الخاص بي", + "@premiumLimitButton": { + "description": "Button text for the bottom sheet when a premium user hits a content limit." } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7807de69..0d1c09c9 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1189,5 +1189,41 @@ "example": "1.1.0" } } + }, + "anonymousLimitTitle": "Sign in to Save More", + "@anonymousLimitTitle": { + "description": "Title for the bottom sheet when an anonymous user hits a content limit." + }, + "anonymousLimitBody": "Create a free account to save and follow unlimited topics, sources, and countries.", + "@anonymousLimitBody": { + "description": "Body text for the bottom sheet when an anonymous user hits a content limit." + }, + "anonymousLimitButton": "Sign In & Link Account", + "@anonymousLimitButton": { + "description": "Button text for the bottom sheet when an anonymous user hits a content limit." + }, + "standardLimitTitle": "Unlock Unlimited Access", + "@standardLimitTitle": { + "description": "Title for the bottom sheet when a standard user hits a content limit." + }, + "standardLimitBody": "You've reached your limit for the free plan. Upgrade to save and follow more.", + "@standardLimitBody": { + "description": "Body text for the bottom sheet when a standard user hits a content limit." + }, + "standardLimitButton": "Upgrade to Premium", + "@standardLimitButton": { + "description": "Button text for the bottom sheet when a standard user hits a content limit." + }, + "premiumLimitTitle": "You've Reached the Limit", + "@premiumLimitTitle": { + "description": "Title for the bottom sheet when a premium user hits a content limit." + }, + "premiumLimitBody": "To add new items, please review and manage your existing saved and followed content.", + "@premiumLimitBody": { + "description": "Body text for the bottom sheet when a premium user hits a content limit." + }, + "premiumLimitButton": "Manage My Content", + "@premiumLimitButton": { + "description": "Button text for the bottom sheet when a premium user hits a content limit." } } \ No newline at end of file From c3d7971344bd4a385b34d41c3aa8db9db0cf8118 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 7 Oct 2025 08:54:09 +0100 Subject: [PATCH 07/13] fix(shared): resolve naming conflict with UiKitLocalizations - Hide UiKitLocalizations from ui_kit package to avoid conflict - Replace context.l10n with AppLocalizationsX(context).l10n for consistent localization - Update localized strings for content limitation views --- .../content_limitation_bottom_sheet.dart | 39 +++++++------------ 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/lib/shared/widgets/content_limitation_bottom_sheet.dart b/lib/shared/widgets/content_limitation_bottom_sheet.dart index bc27d2d9..932ba269 100644 --- a/lib/shared/widgets/content_limitation_bottom_sheet.dart +++ b/lib/shared/widgets/content_limitation_bottom_sheet.dart @@ -3,7 +3,7 @@ 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:go_router/go_router.dart'; -import 'package:ui_kit/ui_kit.dart'; +import 'package:ui_kit/ui_kit.dart' hide UiKitLocalizations; /// {@template content_limitation_bottom_sheet} /// A bottom sheet that informs the user about content limitations and provides @@ -40,16 +40,13 @@ class _AnonymousLimitView extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); return _BaseLimitView( icon: Icons.person_add_alt_1_outlined, - // TODO(fulleni): Add l10n.anonymousLimitTitle - title: 'Sign in to Save More', - // TODO(fulleni): Add l10n.anonymousLimitBody - body: - 'Create a free account to save and follow unlimited topics, sources, and countries.', + title: l10n.anonymousLimitTitle, + body: l10n.anonymousLimitBody, child: ElevatedButton( onPressed: () { // Pop the bottom sheet first. @@ -60,8 +57,7 @@ class _AnonymousLimitView extends StatelessWidget { style: ElevatedButton.styleFrom( minimumSize: const Size.fromHeight(AppSpacing.xxl + AppSpacing.sm), ), - // TODO(fulleni): Add l10n.anonymousLimitButton - child: const Text('Sign In & Link Account'), + child: Text(l10n.anonymousLimitButton), ), ); } @@ -73,16 +69,13 @@ class _StandardUserLimitView extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; final theme = Theme.of(context); return _BaseLimitView( icon: Icons.workspace_premium_outlined, - // TODO(fulleni): Add l10n.standardLimitTitle - title: 'Unlock Unlimited Access', - // TODO(fulleni): Add l10n.standardLimitBody - body: - "You've reached your limit for the free plan. Upgrade to save and follow more.", + title: l10n.standardLimitTitle, + body: l10n.standardLimitBody, child: ElevatedButton( // TODO(fulleni): Implement account upgrade flow. // The upgrade flow is not yet implemented, so the button is disabled. @@ -90,8 +83,7 @@ class _StandardUserLimitView extends StatelessWidget { style: ElevatedButton.styleFrom( minimumSize: const Size.fromHeight(AppSpacing.xxl + AppSpacing.sm), ), - // TODO(fulleni): Add l10n.standardLimitButton - child: const Text('Upgrade to Premium'), + child: Text(l10n.standardLimitButton), ), ); } @@ -103,16 +95,14 @@ class _PremiumUserLimitView extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = context.l10n; + final l10n = AppLocalizationsX(context).l10n; + final theme = Theme.of(context); return _BaseLimitView( icon: Icons.inventory_2_outlined, - // TODO(fulleni): Add l10n.premiumLimitTitle - title: "You've Reached the Limit", - // TODO(fulleni): Add l10n.premiumLimitBody - body: - 'To add new items, please review and manage your existing saved and followed content.', + title: l10n.premiumLimitTitle, + body: l10n.premiumLimitBody, child: ElevatedButton( onPressed: () { // Pop the bottom sheet first. @@ -123,8 +113,7 @@ class _PremiumUserLimitView extends StatelessWidget { style: ElevatedButton.styleFrom( minimumSize: const Size.fromHeight(AppSpacing.xxl + AppSpacing.sm), ), - // TODO(fulleni): Add l10n.premiumLimitButton - child: const Text('Manage My Content'), + child: Text(l10n.premiumLimitButton), ), ); } From 5ce7054fd26b851c4abeb04def54629c4a3544de Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 7 Oct 2025 09:09:08 +0100 Subject: [PATCH 08/13] refactor(shared): improve content limitation logic and naming - Update logic for checking content action limitations based on user role - Rename 'limits' variable to 'userPreferenceConfig' for clarity - Simplify limitation checks using switch cases instead of maps - Combine followTopic, followSource, and followCountry cases - Remove unnecessary whitespace and comments --- .../services/content_limitation_service.dart | 52 ++++++++++++------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/lib/shared/services/content_limitation_service.dart b/lib/shared/services/content_limitation_service.dart index 0d49ecdf..5f74f570 100644 --- a/lib/shared/services/content_limitation_service.dart +++ b/lib/shared/services/content_limitation_service.dart @@ -1,7 +1,6 @@ import 'package:core/core.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; - /// Defines the specific type of content-related action a user is trying to /// perform, which may be subject to limitations. enum ContentAction { @@ -33,7 +32,6 @@ enum LimitationStatus { premiumUserLimitReached, } - /// {@template content_limitation_service} /// A service that centralizes the logic for checking if a user can perform /// a content-related action based on their role and remote configuration limits. @@ -65,35 +63,53 @@ class ContentLimitationService { return LimitationStatus.allowed; } - final limits = remoteConfig.contentConfiguration; + final limits = remoteConfig.userPreferenceConfig; final role = user.appRole; switch (action) { case ContentAction.bookmarkHeadline: final count = preferences.savedHeadlines.length; - final limit = limits.savedHeadlinesLimit[role]; - if (limit != null && count >= limit) { + final int limit; + switch (role) { + case AppUserRole.guestUser: + limit = limits.guestSavedHeadlinesLimit; + case AppUserRole.standardUser: + limit = limits.authenticatedSavedHeadlinesLimit; + case AppUserRole.premiumUser: + limit = limits.premiumSavedHeadlinesLimit; + } + if (count >= limit) { return _getLimitationStatusForRole(role); } case ContentAction.followTopic: - final count = preferences.followedTopics.length; - final limit = limits.followedTopicsLimit[role]; - if (limit != null && count >= limit) { - return _getLimitationStatusForRole(role); + case ContentAction.followSource: + case ContentAction.followCountry: + final int limit; + switch (role) { + case AppUserRole.guestUser: + limit = limits.guestFollowedItemsLimit; + case AppUserRole.standardUser: + limit = limits.authenticatedFollowedItemsLimit; + case AppUserRole.premiumUser: + limit = limits.premiumFollowedItemsLimit; } - case ContentAction.followSource: - final count = preferences.followedSources.length; - final limit = limits.followedSourcesLimit[role]; - if (limit != null && count >= limit) { - return _getLimitationStatusForRole(role); + // Determine the count for the specific item type being followed. + final int count; + switch (action) { + case ContentAction.followTopic: + count = preferences.followedTopics.length; + case ContentAction.followSource: + count = preferences.followedSources.length; + case ContentAction.followCountry: + count = preferences.followedCountries.length; + case ContentAction.bookmarkHeadline: + // This case is handled above and will not be reached here. + count = 0; } - case ContentAction.followCountry: - final count = preferences.followedCountries.length; - final limit = limits.followedCountriesLimit[role]; - if (limit != null && count >= limit) { + if (count >= limit) { return _getLimitationStatusForRole(role); } } From e4c5a1be7982c21a2c4efa2caa3d16e1d8ffedf8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 7 Oct 2025 09:09:22 +0100 Subject: [PATCH 09/13] fix(app): reorder imports to maintain alphabetical order - Reorganized import statements in lib/app/view/app.dart - Ensured imports are in alphabetical order for better code organization --- lib/app/view/app.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 00ab3639..b53cc2e5 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -15,8 +15,8 @@ import 'package:flutter_news_app_mobile_client_full_source_code/app/services/pac import 'package:flutter_news_app_mobile_client_full_source_code/authentication/bloc/authentication_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/router/router.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/status/view/view.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/status/view/view.dart'; import 'package:go_router/go_router.dart'; import 'package:kv_storage_service/kv_storage_service.dart'; import 'package:logging/logging.dart'; From 207f5d04fe36c456747253d9e314601003f14e0d Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 7 Oct 2025 09:30:07 +0100 Subject: [PATCH 10/13] fix(l10n): refine anonymous user limit texts - Update title, body, and button text for anonymous user content limits - Improve consistency and clarity of messages - Remove reference to linking account in button text --- lib/l10n/app_localizations.dart | 8 ++++---- lib/l10n/app_localizations_ar.dart | 6 +++--- lib/l10n/app_localizations_en.dart | 8 ++++---- lib/l10n/arb/app_ar.arb | 6 +++--- lib/l10n/arb/app_en.arb | 8 ++++---- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d99d3585..b9a027d8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1727,25 +1727,25 @@ abstract class AppLocalizations { /// Title for the bottom sheet when an anonymous user hits a content limit. /// /// In en, this message translates to: - /// **'Sign in to Save More'** + /// **'Sign in to Do More'** String get anonymousLimitTitle; /// Body text for the bottom sheet when an anonymous user hits a content limit. /// /// In en, this message translates to: - /// **'Create a free account to save and follow unlimited topics, sources, and countries.'** + /// **'Create a free account to bookmark more and follow more.'** String get anonymousLimitBody; /// Button text for the bottom sheet when an anonymous user hits a content limit. /// /// In en, this message translates to: - /// **'Sign In & Link Account'** + /// **'Sign In'** String get anonymousLimitButton; /// Title for the bottom sheet when a standard user hits a content limit. /// /// In en, this message translates to: - /// **'Unlock Unlimited Access'** + /// **'Unlock More Access'** String get standardLimitTitle; /// Body text for the bottom sheet when a standard user hits a content limit. diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index aa9a40f6..e8b36c64 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -906,14 +906,14 @@ class AppLocalizationsAr extends AppLocalizations { } @override - String get anonymousLimitTitle => 'سجّل الدخول لحفظ المزيد'; + String get anonymousLimitTitle => 'تسجيل الدخول للقيام بالمزيد'; @override String get anonymousLimitBody => - 'أنشئ حسابًا مجانيًا لحفظ ومتابعة عدد غير محدود من المواضيع والمصادر والدول.'; + 'قم بإنشاء حساب مجاني لإضافة المزيد ومتابعة المزيد.'; @override - String get anonymousLimitButton => 'تسجيل الدخول وربط الحساب'; + String get anonymousLimitButton => 'تسجيل الدخول'; @override String get standardLimitTitle => 'افتح الوصول غير المحدود'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 68afbe61..337c9f84 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -907,17 +907,17 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get anonymousLimitTitle => 'Sign in to Save More'; + String get anonymousLimitTitle => 'Sign in to Do More'; @override String get anonymousLimitBody => - 'Create a free account to save and follow unlimited topics, sources, and countries.'; + 'Create a free account to bookmark more and follow more.'; @override - String get anonymousLimitButton => 'Sign In & Link Account'; + String get anonymousLimitButton => 'Sign In'; @override - String get standardLimitTitle => 'Unlock Unlimited Access'; + String get standardLimitTitle => 'Unlock More Access'; @override String get standardLimitBody => diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 03c936cd..3c7712c5 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1190,15 +1190,15 @@ } } }, - "anonymousLimitTitle": "سجّل الدخول لحفظ المزيد", + "anonymousLimitTitle": "تسجيل الدخول للقيام بالمزيد", "@anonymousLimitTitle": { "description": "Title for the bottom sheet when an anonymous user hits a content limit." }, - "anonymousLimitBody": "أنشئ حسابًا مجانيًا لحفظ ومتابعة عدد غير محدود من المواضيع والمصادر والدول.", + "anonymousLimitBody": "قم بإنشاء حساب مجاني لإضافة المزيد ومتابعة المزيد.", "@anonymousLimitBody": { "description": "Body text for the bottom sheet when an anonymous user hits a content limit." }, - "anonymousLimitButton": "تسجيل الدخول وربط الحساب", + "anonymousLimitButton": "تسجيل الدخول", "@anonymousLimitButton": { "description": "Button text for the bottom sheet when an anonymous user hits a content limit." }, diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 0d1c09c9..9856516e 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1190,19 +1190,19 @@ } } }, - "anonymousLimitTitle": "Sign in to Save More", + "anonymousLimitTitle": "Sign in to Do More", "@anonymousLimitTitle": { "description": "Title for the bottom sheet when an anonymous user hits a content limit." }, - "anonymousLimitBody": "Create a free account to save and follow unlimited topics, sources, and countries.", + "anonymousLimitBody": "Create a free account to bookmark more and follow more.", "@anonymousLimitBody": { "description": "Body text for the bottom sheet when an anonymous user hits a content limit." }, - "anonymousLimitButton": "Sign In & Link Account", + "anonymousLimitButton": "Sign In", "@anonymousLimitButton": { "description": "Button text for the bottom sheet when an anonymous user hits a content limit." }, - "standardLimitTitle": "Unlock Unlimited Access", + "standardLimitTitle": "Unlock More Access", "@standardLimitTitle": { "description": "Title for the bottom sheet when a standard user hits a content limit." }, From 5abe9ea1596a6f125df23729344cf2e38755d993 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 7 Oct 2025 09:53:20 +0100 Subject: [PATCH 11/13] refactor(router): remove unused authentication routes - Removed unused routes: forgotPassword, resetPassword, and confirmEmail - This change simplifies the routes configuration and removes references to routes that are not currently in use --- lib/router/routes.dart | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/router/routes.dart b/lib/router/routes.dart index 2d89d1a7..79a92b4b 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -44,12 +44,6 @@ abstract final class Routes { // --- Authentication Routes --- static const authentication = '/authentication'; static const authenticationName = 'authentication'; - static const forgotPassword = 'forgot-password'; - static const forgotPasswordName = 'forgotPassword'; - static const resetPassword = 'reset-password'; - static const resetPasswordName = 'resetPassword'; - static const confirmEmail = 'confirm-email'; - static const confirmEmailName = 'confirmEmail'; // Top-level account linking route static const accountLinking = '/account-linking'; From 9fa045e2e2fbfe08fa1a7f41053f4909416121d4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 7 Oct 2025 10:02:22 +0100 Subject: [PATCH 12/13] refactor(entity_details): improve content action handling - Replace if-else statement with switch expression for contentType - Simplify action assignment using switch expression - Add default case to return null for unknown contentType values - Move return statement outside of switch expression --- .../view/entity_details_page.dart | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index 0e32527f..a75d458b 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -180,16 +180,15 @@ class _EntityDetailsViewState extends State { if (contentType == null) return; - final ContentAction action; - switch (contentType) { - case ContentType.topic: - action = ContentAction.followTopic; - case ContentType.source: - action = ContentAction.followSource; - case ContentType.country: - action = ContentAction.followCountry; - case ContentType.headline: - return; + final action = switch (contentType) { + ContentType.topic => ContentAction.followTopic, + ContentType.source => ContentAction.followSource, + ContentType.country => ContentAction.followCountry, + _ => null, + }; + + if (action == null) { + return; } final status = limitationService.checkAction(action); From 540d7a36581143dfddcb427e52a08a734e70ff8d Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 7 Oct 2025 10:09:22 +0100 Subject: [PATCH 13/13] refactor(widget): use theme-aware styles for content limitation bottom sheet - Replace hardcoded colors and font sizes with theme-provided values - Use Theme.of(context) to obtain current theme data - Apply textTheme and colorScheme for consistent styling - Improve accessibility and adaptability to different theme settings --- .../widgets/content_limitation_bottom_sheet.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/shared/widgets/content_limitation_bottom_sheet.dart b/lib/shared/widgets/content_limitation_bottom_sheet.dart index 932ba269..f44f84eb 100644 --- a/lib/shared/widgets/content_limitation_bottom_sheet.dart +++ b/lib/shared/widgets/content_limitation_bottom_sheet.dart @@ -135,24 +135,24 @@ class _BaseLimitView extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; + return Padding( padding: const EdgeInsets.all(AppSpacing.paddingLarge), child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: AppSpacing.xxl * 1.5, color: Colors.blue), + Icon(icon, size: AppSpacing.xxl * 1.5, color: colorScheme.primary), const SizedBox(height: AppSpacing.lg), Text( title, - style: const TextStyle(fontSize: 22), + style: textTheme.headlineSmall, textAlign: TextAlign.center, ), const SizedBox(height: AppSpacing.md), - Text( - body, - style: const TextStyle(fontSize: 16), - textAlign: TextAlign.center, - ), + Text(body, style: textTheme.bodyLarge, textAlign: TextAlign.center), const SizedBox(height: AppSpacing.lg), child, ],