diff --git a/lib/app/services/app_status_service.dart b/lib/app/services/app_status_service.dart new file mode 100644 index 0000000..a27f4cc --- /dev/null +++ b/lib/app/services/app_status_service.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +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'; + +/// {@template app_status_service} +/// A service dedicated to monitoring the application's lifecycle and +/// proactively triggering status checks. +/// +/// This service ensures that the application can react to server-side +/// status changes (like maintenance mode or forced updates) in real-time, +/// both when the app is resumed from the background and during extended +/// foreground sessions. +/// +/// It works by: +/// 1. Implementing [WidgetsBindingObserver] to listen for app lifecycle events. +/// 2. Triggering a remote configuration fetch via the [AppBloc] whenever the +/// app is resumed (`AppLifecycleState.resumed`). +/// 3. Using a periodic [Timer] to trigger fetches at a regular interval, +/// catching status changes even if the app remains in the foreground. +/// {@endtemplate} +class AppStatusService with WidgetsBindingObserver { + /// {@macro app_status_service} + /// + /// Requires a [BuildContext] to access the [AppBloc] and a [Duration] + /// for the periodic check interval. + AppStatusService({ + required BuildContext context, + required Duration checkInterval, + }) : _context = context, + _checkInterval = checkInterval { + // Immediately register this service as a lifecycle observer. + WidgetsBinding.instance.addObserver(this); + // Start the periodic checks. + _startPeriodicChecks(); + } + + /// The build context used to look up the AppBloc. + final BuildContext _context; + + /// The interval at which to perform periodic status checks. + final Duration _checkInterval; + + /// The timer responsible for periodic checks. + Timer? _timer; + + /// Starts the periodic timer to trigger config fetches. + /// + /// This ensures that even if the app stays in the foreground, it will + /// eventually learn about new server-side status changes. + void _startPeriodicChecks() { + // Cancel any existing timer to prevent duplicates. + _timer?.cancel(); + // Create a new periodic timer. + _timer = Timer.periodic(_checkInterval, (_) { + print( + '[AppStatusService] Periodic check triggered. Requesting AppConfig fetch.', + ); + // Add the event to the AppBloc to fetch the latest config. + _context.read().add(const AppConfigFetchRequested()); + }); + } + + /// Overridden from [WidgetsBindingObserver]. + /// + /// This method is called whenever the application's lifecycle state changes. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // We are only interested in the 'resumed' state. + if (state == AppLifecycleState.resumed) { + print( + '[AppStatusService] App resumed. Requesting AppConfig fetch.', + ); + // When the app comes to the foreground, immediately trigger a check. + // This is crucial for catching maintenance mode that was enabled + // while the app was in the background. + _context.read().add(const AppConfigFetchRequested()); + } + } + + /// Cleans up resources used by the service. + /// + /// This must be called when the service is no longer needed (e.g., when + /// the main app widget is disposed) to prevent memory leaks from the + /// timer and the observer registration. + void dispose() { + print('[AppStatusService] Disposing service.'); + // Stop the periodic timer. + _timer?.cancel(); + // Remove this object from the list of lifecycle observers. + WidgetsBinding.instance.removeObserver(this); + } +} diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 06c633a..3f7c98e 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -6,10 +6,12 @@ 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/app/config/app_environment.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/services/app_status_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/services/demo_data_migration_service.dart'; 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:go_router/go_router.dart'; import 'package:kv_storage_service/kv_storage_service.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -132,6 +134,8 @@ class _AppViewState extends State<_AppView> { late final GoRouter _router; // Standard notifier that GoRouter listens to. late final ValueNotifier _statusNotifier; + // The service responsible for automated status checks. + AppStatusService? _appStatusService; // Removed Dynamic Links subscription @override @@ -140,6 +144,15 @@ class _AppViewState extends State<_AppView> { final appBloc = context.read(); // Initialize the notifier with the BLoC's current state _statusNotifier = ValueNotifier(appBloc.state.status); + + // Instantiate and initialize the AppStatusService. + // This service will automatically trigger checks when the app is resumed + // or at periodic intervals, ensuring the app status is always fresh. + _appStatusService = AppStatusService( + context: context, + checkInterval: const Duration(minutes: 15), + ); + _router = createRouter( authStatusNotifier: _statusNotifier, authenticationRepository: widget.authenticationRepository, @@ -159,6 +172,8 @@ class _AppViewState extends State<_AppView> { @override void dispose() { _statusNotifier.dispose(); + // Dispose the AppStatusService to cancel timers and remove observers. + _appStatusService?.dispose(); // Removed Dynamic Links subscription cancellation super.dispose(); } @@ -182,6 +197,25 @@ class _AppViewState extends State<_AppView> { // Defer l10n access until inside a MaterialApp context // Handle critical RemoteConfig loading states globally + // These checks have the highest priority and will lock the entire UI. + // + // Check for Maintenance Mode. + if (state.status == AppStatus.underMaintenance) { + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: MaintenancePage(), + ); + } + + // Check for a Required Update. + if (state.status == AppStatus.updateRequired) { + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: UpdateRequiredPage(), + ); + } + + // Check for Config Fetching state. if (state.status == AppStatus.configFetching) { return MaterialApp( debugShowCheckedModeBanner: false, diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 81acc06..87a42b8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1549,6 +1549,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Bold'** String get settingsAppearanceFontWeightBold; + + /// Headline for the maintenance page + /// + /// In en, this message translates to: + /// **'Under Maintenance'** + String get maintenanceHeadline; + + /// Subheadline for the maintenance page + /// + /// In en, this message translates to: + /// **'We are currently performing maintenance. Please check back later.'** + String get maintenanceSubheadline; + + /// Headline for the force update page + /// + /// In en, this message translates to: + /// **'Update Required'** + String get updateRequiredHeadline; + + /// Subheadline for the force update page + /// + /// In en, this message translates to: + /// **'A new version of the app is available. Please update to continue using the app.'** + String get updateRequiredSubheadline; + + /// Button text for the force update page + /// + /// In en, this message translates to: + /// **'Update Now'** + String get updateRequiredButton; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index ad66db9..4186a56 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -804,4 +804,21 @@ class AppLocalizationsAr extends AppLocalizations { @override String get settingsAppearanceFontWeightBold => 'عريض'; + + @override + String get maintenanceHeadline => 'تحت الصيانة'; + + @override + String get maintenanceSubheadline => + 'نقوم حاليًا بإجراء صيانة. يرجى التحقق مرة أخرى لاحقًا.'; + + @override + String get updateRequiredHeadline => 'التحديث مطلوب'; + + @override + String get updateRequiredSubheadline => + 'يتوفر إصدار جديد من التطبيق. يرجى التحديث لمتابعة استخدام التطبيق.'; + + @override + String get updateRequiredButton => 'التحديث الآن'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 92e2d66..cbbd197 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -804,4 +804,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsAppearanceFontWeightBold => 'Bold'; + + @override + String get maintenanceHeadline => 'Under Maintenance'; + + @override + String get maintenanceSubheadline => + 'We are currently performing maintenance. Please check back later.'; + + @override + String get updateRequiredHeadline => 'Update Required'; + + @override + String get updateRequiredSubheadline => + 'A new version of the app is available. Please update to continue using the app.'; + + @override + String get updateRequiredButton => 'Update Now'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 94fd8b9..8302e12 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1034,5 +1034,25 @@ "settingsAppearanceFontWeightBold": "عريض", "@settingsAppearanceFontWeightBold": { "description": "Label for the bold font weight option" + }, + "maintenanceHeadline": "تحت الصيانة", + "@maintenanceHeadline": { + "description": "Headline for the maintenance page" + }, + "maintenanceSubheadline": "نقوم حاليًا بإجراء صيانة. يرجى التحقق مرة أخرى لاحقًا.", + "@maintenanceSubheadline": { + "description": "Subheadline for the maintenance page" + }, + "updateRequiredHeadline": "التحديث مطلوب", + "@updateRequiredHeadline": { + "description": "Headline for the force update page" + }, + "updateRequiredSubheadline": "يتوفر إصدار جديد من التطبيق. يرجى التحديث لمتابعة استخدام التطبيق.", + "@updateRequiredSubheadline": { + "description": "Subheadline for the force update page" + }, + "updateRequiredButton": "التحديث الآن", + "@updateRequiredButton": { + "description": "Button text for the force update page" } } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 9ccef5b..7147726 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1052,5 +1052,25 @@ "settingsAppearanceFontWeightBold": "Bold", "@settingsAppearanceFontWeightBold": { "description": "Label for the bold font weight option" + }, + "maintenanceHeadline": "Under Maintenance", + "@maintenanceHeadline": { + "description": "Headline for the maintenance page" + }, + "maintenanceSubheadline": "We are currently performing maintenance. Please check back later.", + "@maintenanceSubheadline": { + "description": "Subheadline for the maintenance page" + }, + "updateRequiredHeadline": "Update Required", + "@updateRequiredHeadline": { + "description": "Headline for the force update page" + }, + "updateRequiredSubheadline": "A new version of the app is available. Please update to continue using the app.", + "@updateRequiredSubheadline": { + "description": "Subheadline for the force update page" + }, + "updateRequiredButton": "Update Now", + "@updateRequiredButton": { + "description": "Button text for the force update page" } } diff --git a/lib/status/view/maintenance_page.dart b/lib/status/view/maintenance_page.dart new file mode 100644 index 0000000..8456eb5 --- /dev/null +++ b/lib/status/view/maintenance_page.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// A page displayed to the user when the application is in maintenance mode. +/// +/// This is a simple, static page that informs the user that the app is +/// temporarily unavailable and asks them to check back later. It's designed +/// to be displayed globally, blocking access to all other app features. +class MaintenancePage extends StatelessWidget { + /// {@macro maintenance_page} + const MaintenancePage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + // The Scaffold provides the basic Material Design visual layout structure. + return Scaffold( + body: Padding( + // Use consistent padding from the UI kit. + padding: const EdgeInsets.all(AppSpacing.lg), + // The InitialStateWidget from the UI kit is reused here to provide a + // consistent look and feel for full-screen informational states. + child: InitialStateWidget( + icon: Icons.build_circle_outlined, + headline: l10n.maintenanceHeadline, + subheadline: l10n.maintenanceSubheadline, + ), + ), + ); + } +} diff --git a/lib/status/view/update_required_page.dart b/lib/status/view/update_required_page.dart new file mode 100644 index 0000000..eb7661b --- /dev/null +++ b/lib/status/view/update_required_page.dart @@ -0,0 +1,88 @@ +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/app_localizations.dart'; +import 'package:ui_kit/ui_kit.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// A page displayed to the user when a mandatory app update is required. +/// +/// This page informs the user that they must update the application to the +/// latest version to continue using it. It provides a button that links +/// directly to the appropriate app store page by fetching the URL from the +/// remote configuration. +class UpdateRequiredPage extends StatelessWidget { + /// {@macro update_required_page} + const UpdateRequiredPage({super.key}); + + /// Attempts to launch the given URL in an external application (e.g., browser + /// or app store). + /// + /// Shows a [SnackBar] with an error message if the URL cannot be launched. + Future _launchUrl(BuildContext context, String url) async { + // Ensure the URL is not empty before attempting to parse. + if (url.isEmpty) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + // TODO(fulleni): localize later. + const SnackBar(content: Text('Update URL is not available.')), + ); + return; + } + + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + // Launch the URL externally. This will open the App Store, Play Store, + // or a browser. + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + // If the URL can't be launched, inform the user. + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text('Could not open update URL: $url')), + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + // This is the robust, production-ready way to get the update URL. + // It uses BlocProvider.of(context) to access the AppBloc instance and + // determines the correct URL based on the current platform (iOS/Android). + // It falls back to an empty string if the remote config is not available. + final appBloc = BlocProvider.of(context); + final updateUrl = Theme.of(context).platform == TargetPlatform.android + ? appBloc.state.remoteConfig?.appStatus.androidUpdateUrl + : appBloc.state.remoteConfig?.appStatus.iosUpdateUrl; + + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Reusing the InitialStateWidget for a consistent UI. + InitialStateWidget( + icon: Icons.system_update_alt, + headline: l10n.updateRequiredHeadline, + subheadline: l10n.updateRequiredSubheadline, + ), + const SizedBox(height: AppSpacing.lg), + // The button to direct the user to the app store. + // It's disabled if the update URL is not available. + ElevatedButton( + onPressed: updateUrl != null && updateUrl.isNotEmpty + ? () => _launchUrl(context, updateUrl) + : null, + child: Text(l10n.updateRequiredButton), + ), + ], + ), + ), + ); + } +} diff --git a/lib/status/view/view.dart b/lib/status/view/view.dart new file mode 100644 index 0000000..7c24eda --- /dev/null +++ b/lib/status/view/view.dart @@ -0,0 +1,2 @@ +export 'maintenance_page.dart'; +export 'update_required_page.dart';