diff --git a/analysis_options.yaml b/analysis_options.yaml index e324f571..e1e474b8 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,6 +1,8 @@ analyzer: errors: avoid_print: ignore + document_ignores: ignore + lines_longer_than_80_chars: ignore include: package:very_good_analysis/analysis_options.9.0.0.yaml linter: rules: diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index ad5d2b74..04d062af 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -3,8 +3,8 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:ht_auth_repository/ht_auth_repository.dart'; -import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_dashboard/app/config/config.dart' as local_config; +import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart'; part 'app_event.dart'; @@ -21,11 +21,7 @@ class AppBloc extends Bloc { _appConfigRepository = appConfigRepository, _environment = environment, super( - const AppState( - user: null, - status: AppStatus.initial, - environment: null, - ), + const AppState(), ) { on(_onAppUserChanged); on(_onLogoutRequested); @@ -56,7 +52,8 @@ class AppBloc extends Bloc { status = AppStatus.authenticated; // ignore: no_default_cases default: // Fallback for any other roles not explicitly handled - status = AppStatus.unauthenticated; // Treat other roles as unauthenticated for dashboard + status = AppStatus + .unauthenticated; // Treat other roles as unauthenticated for dashboard } // Emit user and status update diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index 04d11c18..70f3c35b 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -48,8 +48,8 @@ class AppState extends Equatable { @override List get props => [ - status, - user, - environment, - ]; + status, + user, + environment, + ]; } diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index f8d3b81d..15ef992d 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -5,14 +5,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:ht_auth_repository/ht_auth_repository.dart'; -import 'package:ht_data_repository/ht_data_repository.dart'; -import 'package:ht_kv_storage_service/ht_kv_storage_service.dart'; import 'package:ht_dashboard/app/bloc/app_bloc.dart'; import 'package:ht_dashboard/app/config/app_environment.dart'; import 'package:ht_dashboard/authentication/bloc/authentication_bloc.dart'; import 'package:ht_dashboard/l10n/app_localizations.dart'; -import 'package:ht_dashboard/l10n/l10n.dart'; import 'package:ht_dashboard/router/router.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_kv_storage_service/ht_kv_storage_service.dart'; import 'package:ht_shared/ht_shared.dart'; class App extends StatelessWidget { @@ -24,7 +23,7 @@ class App extends StatelessWidget { required HtDataRepository htSourcesRepository, required HtDataRepository htUserAppSettingsRepository, required HtDataRepository - htUserContentPreferencesRepository, + htUserContentPreferencesRepository, required HtDataRepository htAppConfigRepository, required HtKVStorageService kvStorageService, required AppEnvironment environment, @@ -47,7 +46,7 @@ class App extends StatelessWidget { final HtDataRepository _htSourcesRepository; final HtDataRepository _htUserAppSettingsRepository; final HtDataRepository - _htUserContentPreferencesRepository; + _htUserContentPreferencesRepository; final HtDataRepository _htAppConfigRepository; final HtKVStorageService _kvStorageService; final AppEnvironment _environment; @@ -71,8 +70,8 @@ class App extends StatelessWidget { BlocProvider( create: (context) => AppBloc( authenticationRepository: context.read(), - userAppSettingsRepository: - context.read>(), + userAppSettingsRepository: context + .read>(), appConfigRepository: context.read>(), environment: _environment, ), diff --git a/lib/app/view/app_shell.dart b/lib/app/view/app_shell.dart index 604e6f1c..f48f7de7 100644 --- a/lib/app/view/app_shell.dart +++ b/lib/app/view/app_shell.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; import 'package:go_router/go_router.dart'; import 'package:ht_dashboard/l10n/l10n.dart'; -import 'package:ht_dashboard/router/routes.dart'; /// A responsive scaffold shell for the main application sections. /// diff --git a/lib/app_configuration/bloc/app_configuration_bloc.dart b/lib/app_configuration/bloc/app_configuration_bloc.dart new file mode 100644 index 00000000..61d4c9ec --- /dev/null +++ b/lib/app_configuration/bloc/app_configuration_bloc.dart @@ -0,0 +1,99 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; // Use AppConfig from ht_shared + +part 'app_configuration_event.dart'; +part 'app_configuration_state.dart'; + +class AppConfigurationBloc + extends Bloc { + AppConfigurationBloc({ + required HtDataRepository appConfigRepository, + }) : _appConfigRepository = appConfigRepository, + super(const AppConfigurationState()) { + on(_onAppConfigurationLoaded); + on(_onAppConfigurationUpdated); + on(_onAppConfigurationFieldChanged); + } + + final HtDataRepository _appConfigRepository; + + Future _onAppConfigurationLoaded( + AppConfigurationLoaded event, + Emitter emit, + ) async { + emit(state.copyWith(status: AppConfigurationStatus.loading)); + try { + final appConfig = await _appConfigRepository.read(id: 'app_config'); + emit( + state.copyWith( + status: AppConfigurationStatus.success, + appConfig: appConfig, + isDirty: false, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: AppConfigurationStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: AppConfigurationStatus.failure, + errorMessage: 'An unknown error occurred: $e', + ), + ); + } + } + + Future _onAppConfigurationUpdated( + AppConfigurationUpdated event, + Emitter emit, + ) async { + emit(state.copyWith(status: AppConfigurationStatus.loading)); + try { + final updatedConfig = await _appConfigRepository.update( + id: event.appConfig.id, + item: event.appConfig, + ); + emit( + state.copyWith( + status: AppConfigurationStatus.success, + appConfig: updatedConfig, + isDirty: false, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: AppConfigurationStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: AppConfigurationStatus.failure, + errorMessage: 'An unknown error occurred: $e', + ), + ); + } + } + + void _onAppConfigurationFieldChanged( + AppConfigurationFieldChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + appConfig: event.appConfig, + isDirty: true, + clearErrorMessage: true, // Clear any previous error messages + ), + ); + } +} diff --git a/lib/app_configuration/bloc/app_configuration_event.dart b/lib/app_configuration/bloc/app_configuration_event.dart new file mode 100644 index 00000000..fdf8b1a0 --- /dev/null +++ b/lib/app_configuration/bloc/app_configuration_event.dart @@ -0,0 +1,53 @@ +part of 'app_configuration_bloc.dart'; + +/// Abstract base class for all events in the AppConfigurationBloc. +abstract class AppConfigurationEvent extends Equatable { + /// {@macro app_configuration_event} + const AppConfigurationEvent(); + + @override + List get props => []; +} + +/// {@template app_configuration_loaded} +/// Event to request the loading of the application configuration. +/// {@endtemplate} +class AppConfigurationLoaded extends AppConfigurationEvent { + /// {@macro app_configuration_loaded} + const AppConfigurationLoaded(); +} + +/// {@template app_configuration_updated} +/// Event to request the update of the application configuration. +/// +/// Carries the new [appConfig] object to be saved. +/// {@endtemplate} +class AppConfigurationUpdated extends AppConfigurationEvent { + /// {@macro app_configuration_updated} + const AppConfigurationUpdated(this.appConfig); + + /// The updated application configuration. + final AppConfig appConfig; + + @override + List get props => [appConfig]; +} + +/// {@template app_configuration_field_changed} +/// Event to notify that a field in the application configuration has changed. +/// +/// This event is used to update the local state and mark it as dirty, +/// without immediately triggering a backend save. +/// {@endtemplate} +class AppConfigurationFieldChanged extends AppConfigurationEvent { + /// {@macro app_configuration_field_changed} + const AppConfigurationFieldChanged({ + this.appConfig, + }); + + /// The partially or fully updated AppConfig object. + final AppConfig? appConfig; + + @override + List get props => [appConfig]; +} diff --git a/lib/app_configuration/bloc/app_configuration_state.dart b/lib/app_configuration/bloc/app_configuration_state.dart new file mode 100644 index 00000000..f91ce76d --- /dev/null +++ b/lib/app_configuration/bloc/app_configuration_state.dart @@ -0,0 +1,67 @@ +part of 'app_configuration_bloc.dart'; + +/// Represents the status of the AppConfigurationBloc. +enum AppConfigurationStatus { + /// The configuration is in its initial state. + initial, + + /// The configuration is currently being loaded. + loading, + + /// The configuration has been successfully loaded or updated. + success, + + /// An error occurred during loading or updating the configuration. + failure, +} + +/// {@template app_configuration_state} +/// State for the AppConfigurationBloc. +/// {@endtemplate} +class AppConfigurationState extends Equatable { + /// {@macro app_configuration_state} + const AppConfigurationState({ + this.status = AppConfigurationStatus.initial, + this.appConfig, + this.errorMessage, + this.isDirty = false, + }); + + /// The current status of the application configuration. + final AppConfigurationStatus status; + + /// The loaded or updated application configuration. + final AppConfig? appConfig; + + /// An error message if an operation failed. + final String? errorMessage; + + /// Indicates if there are unsaved changes to the configuration. + final bool isDirty; + + /// Creates a copy of the current state with updated values. + AppConfigurationState copyWith({ + AppConfigurationStatus? status, + AppConfig? appConfig, + String? errorMessage, + bool? isDirty, + bool clearErrorMessage = false, + }) { + return AppConfigurationState( + status: status ?? this.status, + appConfig: appConfig ?? this.appConfig, + errorMessage: clearErrorMessage + ? null + : errorMessage ?? this.errorMessage, + isDirty: isDirty ?? this.isDirty, + ); + } + + @override + List get props => [ + status, + appConfig, + errorMessage, + isDirty, + ]; +} diff --git a/lib/app_configuration/view/app_configuration_page.dart b/lib/app_configuration/view/app_configuration_page.dart index 09833605..da2fab0e 100644 --- a/lib/app_configuration/view/app_configuration_page.dart +++ b/lib/app_configuration/view/app_configuration_page.dart @@ -1,17 +1,942 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:ht_dashboard/app_configuration/bloc/app_configuration_bloc.dart'; +import 'package:ht_dashboard/shared/constants/app_spacing.dart'; +import 'package:ht_dashboard/shared/widgets/widgets.dart'; +import 'package:ht_shared/ht_shared.dart'; // For AppConfig and its nested models /// {@template app_configuration_page} -/// A placeholder page for App Configuration. +/// A page for managing the application's remote configuration. +/// +/// This page allows administrators to view and modify various application +/// settings that affect the live mobile app. Due to the sensitive nature +/// of these settings, changes require explicit confirmation. /// {@endtemplate} -class AppConfigurationPage extends StatelessWidget { +class AppConfigurationPage extends StatefulWidget { /// {@macro app_configuration_page} const AppConfigurationPage({super.key}); + @override + State createState() => _AppConfigurationPageState(); +} + +class _AppConfigurationPageState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController( + length: 5, + vsync: this, + ); // 5 tabs for AppConfig properties + context.read().add(const AppConfigurationLoaded()); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: Text('App Configuration Page'), + return Scaffold( + appBar: AppBar( + title: Text( + 'App Configuration', + style: Theme.of(context).textTheme.headlineSmall, + ), + bottom: TabBar( + controller: _tabController, + isScrollable: true, + tabs: const [ + Tab(text: 'User Preferences'), + Tab(text: 'Ad Config'), + Tab(text: 'Account Actions'), + Tab(text: 'Kill Switch'), + Tab(text: 'Force Update'), + ], + ), + ), + body: BlocConsumer( + listener: (context, state) { + if (state.status == AppConfigurationStatus.success && + state.isDirty == false) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + 'App configuration saved successfully!', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + backgroundColor: Theme.of(context).colorScheme.primary, + ), + ); + } else if (state.status == AppConfigurationStatus.failure) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + 'Error: ${state.errorMessage ?? "Unknown error"}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onError, + ), + ), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + }, + builder: (context, state) { + if (state.status == AppConfigurationStatus.loading || + state.status == AppConfigurationStatus.initial) { + return const LoadingStateWidget( + icon: Icons.settings_applications_outlined, + headline: 'Loading Configuration', + subheadline: 'Please wait while settings are loaded...', + ); + } else if (state.status == AppConfigurationStatus.failure) { + return FailureStateWidget( + message: state.errorMessage ?? 'Failed to load configuration.', + onRetry: () { + context.read().add( + const AppConfigurationLoaded(), + ); + }, + ); + } else if (state.status == AppConfigurationStatus.success && + state.appConfig != null) { + final appConfig = state.appConfig!; + return TabBarView( + controller: _tabController, + children: [ + _buildUserPreferenceLimitsTab(context, appConfig), + _buildAdConfigTab(context, appConfig), + _buildAccountActionConfigTab(context, appConfig), + _buildKillSwitchTab(context, appConfig), + _buildForceUpdateTab(context, appConfig), + ], + ); + } + return const InitialStateWidget( + icon: Icons.settings_applications_outlined, + headline: 'App Configuration', + subheadline: 'Load application settings from the backend.', + ); // Fallback + }, + ), + bottomNavigationBar: _buildBottomAppBar(context), + ); + } + + Widget _buildBottomAppBar(BuildContext context) { + final isDirty = context.select( + (AppConfigurationBloc bloc) => bloc.state.isDirty, + ); + final appConfig = context.select( + (AppConfigurationBloc bloc) => bloc.state.appConfig, + ); + + return BottomAppBar( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: isDirty + ? () { + // Discard changes: reload original config + context.read().add( + const AppConfigurationLoaded(), + ); + } + : null, + child: const Text('Discard Changes'), + ), + const SizedBox(width: AppSpacing.md), + ElevatedButton( + onPressed: isDirty + ? () async { + final confirmed = await _showConfirmationDialog(context); + if (confirmed && appConfig != null) { + context.read().add( + AppConfigurationUpdated(appConfig), + ); + } + } + : null, + child: const Text('Save Changes'), + ), + ], + ), + ), + ); + } + + Future _showConfirmationDialog(BuildContext context) async { + return await showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: Text( + 'Confirm Configuration Update', + style: Theme.of(dialogContext).textTheme.titleLarge, + ), + content: Text( + 'Are you sure you want to apply these changes to the live application configuration? This is a critical operation.', + style: Theme.of(dialogContext).textTheme.bodyMedium, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(dialogContext).colorScheme.error, + foregroundColor: Theme.of( + dialogContext, + ).colorScheme.onError, + ), + child: const Text('Confirm Save'), + ), + ], + ); + }, + ) ?? + false; + } + + Widget _buildUserPreferenceLimitsTab( + BuildContext context, + AppConfig appConfig, + ) { + final userPreferenceLimits = appConfig.userPreferenceLimits; + return SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'User Preference Limits', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: AppSpacing.md), + Text( + 'These settings define the maximum number of items a user can follow or save, tiered by user role. Changes here directly impact user capabilities.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: AppSpacing.lg), + _buildIntField( + context, + label: 'Guest Followed Items Limit', + description: + 'Max countries, sources, or categories a Guest user can follow (each).', + value: userPreferenceLimits.guestFollowedItemsLimit, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + userPreferenceLimits: userPreferenceLimits.copyWith( + guestFollowedItemsLimit: value, + ), + ), + ), + ); + }, + ), + _buildIntField( + context, + label: 'Guest Saved Headlines Limit', + description: 'Max headlines a Guest user can save.', + value: userPreferenceLimits.guestSavedHeadlinesLimit, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + userPreferenceLimits: userPreferenceLimits.copyWith( + guestSavedHeadlinesLimit: value, + ), + ), + ), + ); + }, + ), + _buildIntField( + context, + label: 'Authenticated Followed Items Limit', + description: + 'Max countries, sources, or categories an Authenticated user can follow (each).', + value: userPreferenceLimits.authenticatedFollowedItemsLimit, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + userPreferenceLimits: userPreferenceLimits.copyWith( + authenticatedFollowedItemsLimit: value, + ), + ), + ), + ); + }, + ), + _buildIntField( + context, + label: 'Authenticated Saved Headlines Limit', + description: 'Max headlines an Authenticated user can save.', + value: userPreferenceLimits.authenticatedSavedHeadlinesLimit, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + userPreferenceLimits: userPreferenceLimits.copyWith( + authenticatedSavedHeadlinesLimit: value, + ), + ), + ), + ); + }, + ), + _buildIntField( + context, + label: 'Premium Followed Items Limit', + description: + 'Max countries, sources, or categories a Premium user can follow (each).', + value: userPreferenceLimits.premiumFollowedItemsLimit, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + userPreferenceLimits: userPreferenceLimits.copyWith( + premiumFollowedItemsLimit: value, + ), + ), + ), + ); + }, + ), + _buildIntField( + context, + label: 'Premium Saved Headlines Limit', + description: 'Max headlines a Premium user can save.', + value: userPreferenceLimits.premiumSavedHeadlinesLimit, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + userPreferenceLimits: userPreferenceLimits.copyWith( + premiumSavedHeadlinesLimit: value, + ), + ), + ), + ); + }, + ), + ], + ), + ); + } + + Widget _buildAdConfigTab(BuildContext context, AppConfig appConfig) { + final adConfig = appConfig.adConfig; + return SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ad Configuration', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: AppSpacing.md), + Text( + 'These settings control how ads are injected and displayed in the application, tiered by user role. AdFrequency determines how often an ad can be injected, and AdPlacementInterval sets a minimum number of primary items before the first ad.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: AppSpacing.lg), + _buildIntField( + context, + label: 'Guest Ad Frequency', + description: + 'How often an ad can be injected for Guest users (e.g., 5 means after every 5 primary items).', + value: adConfig.guestAdFrequency, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + adConfig: adConfig.copyWith(guestAdFrequency: value), + ), + ), + ); + }, + ), + _buildIntField( + context, + label: 'Guest Ad Placement Interval', + description: + 'Minimum primary items before the first ad for Guest users.', + value: adConfig.guestAdPlacementInterval, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + adConfig: adConfig.copyWith( + guestAdPlacementInterval: value, + ), + ), + ), + ); + }, + ), + _buildIntField( + context, + label: 'Authenticated Ad Frequency', + description: + 'How often an ad can be injected for Authenticated users.', + value: adConfig.authenticatedAdFrequency, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + adConfig: adConfig.copyWith( + authenticatedAdFrequency: value, + ), + ), + ), + ); + }, + ), + _buildIntField( + context, + label: 'Authenticated Ad Placement Interval', + description: + 'Minimum primary items before the first ad for Authenticated users.', + value: adConfig.authenticatedAdPlacementInterval, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + adConfig: adConfig.copyWith( + authenticatedAdPlacementInterval: value, + ), + ), + ), + ); + }, + ), + _buildIntField( + context, + label: 'Premium Ad Frequency', + description: + 'How often an ad can be injected for Premium users (0 for no ads).', + value: adConfig.premiumAdFrequency, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + adConfig: adConfig.copyWith(premiumAdFrequency: value), + ), + ), + ); + }, + ), + _buildIntField( + context, + label: 'Premium Ad Placement Interval', + description: + 'Minimum primary items before the first ad for Premium users.', + value: adConfig.premiumAdPlacementInterval, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + adConfig: adConfig.copyWith( + premiumAdPlacementInterval: value, + ), + ), + ), + ); + }, + ), + _buildIntField( + context, + label: 'Guest Articles Before Interstitial Ads', + description: + 'Number of articles a Guest user reads before an interstitial ad is shown.', + value: adConfig.guestArticlesToReadBeforeShowingInterstitialAds, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + adConfig: adConfig.copyWith( + guestArticlesToReadBeforeShowingInterstitialAds: value, + ), + ), + ), + ); + }, + ), + _buildIntField( + context, + label: 'Standard User Articles Before Interstitial Ads', + description: + 'Number of articles a Standard user reads before an interstitial ad is shown.', + value: + adConfig.standardUserArticlesToReadBeforeShowingInterstitialAds, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + adConfig: adConfig.copyWith( + standardUserArticlesToReadBeforeShowingInterstitialAds: + value, + ), + ), + ), + ); + }, + ), + _buildIntField( + context, + label: 'Premium User Articles Before Interstitial Ads', + description: + 'Number of articles a Premium user reads before an interstitial ad is shown.', + value: + adConfig.premiumUserArticlesToReadBeforeShowingInterstitialAds, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + adConfig: adConfig.copyWith( + premiumUserArticlesToReadBeforeShowingInterstitialAds: + value, + ), + ), + ), + ); + }, + ), + ], + ), + ); + } + + Widget _buildAccountActionConfigTab( + BuildContext context, + AppConfig appConfig, + ) { + final accountActionConfig = appConfig.accountActionConfig; + return SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Account Action Configuration', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: AppSpacing.md), + Text( + 'These settings control the display frequency of in-feed account actions (e.g., link account, upgrade prompts), tiered by user role.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: AppSpacing.lg), + _buildIntField( + context, + label: 'Guest Days Between Account Actions', + description: + 'Minimum days between showing account actions to Guest users.', + value: accountActionConfig.guestDaysBetweenAccountActions, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + accountActionConfig: accountActionConfig.copyWith( + guestDaysBetweenAccountActions: value, + ), + ), + ), + ); + }, + ), + _buildIntField( + context, + label: 'Standard User Days Between Account Actions', + description: + 'Minimum days between showing account actions to Standard users.', + value: accountActionConfig.standardUserDaysBetweenAccountActions, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith( + accountActionConfig: accountActionConfig.copyWith( + standardUserDaysBetweenAccountActions: value, + ), + ), + ), + ); + }, + ), + ], + ), + ); + } + + Widget _buildKillSwitchTab(BuildContext context, AppConfig appConfig) { + return SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Kill Switch & App Status', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: AppSpacing.md), + Text( + 'WARNING: These settings can disable the entire mobile application. Use with extreme caution.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: AppSpacing.lg), + _buildSwitchField( + context, + label: 'Kill Switch Enabled', + description: + "If enabled, the app's operational status will be enforced.", + value: appConfig.killSwitchEnabled, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith(killSwitchEnabled: value), + ), + ); + }, + ), + _buildDropdownField( + context, + label: 'App Operational Status', + description: + 'The current operational status of the app (e.g., active, maintenance, disabled).', + value: appConfig.appOperationalStatus, + items: RemoteAppStatus.values, + itemLabelBuilder: (status) => status.name, + onChanged: (value) { + if (value != null) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith(appOperationalStatus: value), + ), + ); + } + }, + ), + _buildTextField( + context, + label: 'Maintenance Message', + description: + 'Message displayed when the app is in maintenance mode.', + value: appConfig.maintenanceMessage, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith(maintenanceMessage: value), + ), + ); + }, + ), + _buildTextField( + context, + label: 'Disabled Message', + description: + 'Message displayed when the app is permanently disabled.', + value: appConfig.disabledMessage, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith(disabledMessage: value), + ), + ); + }, + ), + ], + ), + ); + } + + Widget _buildForceUpdateTab(BuildContext context, AppConfig appConfig) { + return SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Force Update Configuration', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: AppSpacing.md), + Text( + 'These settings control app version enforcement. Users on versions below the minimum allowed will be forced to update.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: AppSpacing.lg), + _buildTextField( + context, + label: 'Minimum Allowed App Version', + description: + 'The lowest app version allowed to run (e.g., "1.2.0").', + value: appConfig.minAllowedAppVersion, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith(minAllowedAppVersion: value), + ), + ); + }, + ), + _buildTextField( + context, + label: 'Latest App Version', + description: 'The latest available app version (e.g., "1.5.0").', + value: appConfig.latestAppVersion, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith(latestAppVersion: value), + ), + ); + }, + ), + _buildTextField( + context, + label: 'Update Required Message', + description: 'Message displayed when a force update is required.', + value: appConfig.updateRequiredMessage, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith(updateRequiredMessage: value), + ), + ); + }, + ), + _buildTextField( + context, + label: 'Update Optional Message', + description: 'Message displayed for an optional update.', + value: appConfig.updateOptionalMessage, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith(updateOptionalMessage: value), + ), + ); + }, + ), + _buildTextField( + context, + label: 'iOS Store URL', + description: 'URL to the app on the Apple App Store.', + value: appConfig.iosStoreUrl, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith(iosStoreUrl: value), + ), + ); + }, + ), + _buildTextField( + context, + label: 'Android Store URL', + description: 'URL to the app on the Google Play Store.', + value: appConfig.androidStoreUrl, + onChanged: (value) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: appConfig.copyWith(androidStoreUrl: value), + ), + ); + }, + ), + ], + ), + ); + } + + Widget _buildIntField( + BuildContext context, { + required String label, + required String description, + required int value, + required ValueChanged onChanged, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.xs), + Text( + description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: AppSpacing.xs), + TextFormField( + initialValue: value.toString(), + keyboardType: TextInputType.number, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + ), + onChanged: (text) { + final parsedValue = int.tryParse(text); + if (parsedValue != null) { + onChanged(parsedValue); + } + }, + ), + ], + ), + ); + } + + Widget _buildTextField( + BuildContext context, { + required String label, + required String description, + required String? value, + required ValueChanged onChanged, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.xs), + Text( + description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: AppSpacing.xs), + TextFormField( + initialValue: value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + ), + onChanged: onChanged, + ), + ], + ), + ); + } + + Widget _buildSwitchField( + BuildContext context, { + required String label, + required String description, + required bool value, + required ValueChanged onChanged, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.xs), + Text( + description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + SwitchListTile( + title: Text(label), + value: value, + onChanged: onChanged, + contentPadding: EdgeInsets.zero, + ), + ], + ), + ); + } + + Widget _buildDropdownField( + BuildContext context, { + required String label, + required String description, + required T value, + required List items, + required String Function(T) itemLabelBuilder, + required ValueChanged onChanged, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.xs), + Text( + description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: AppSpacing.xs), + DropdownButtonFormField( + value: value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + ), + items: items.map((item) { + return DropdownMenuItem( + value: item, + child: Text(itemLabelBuilder(item)), + ); + }).toList(), + onChanged: onChanged, + ), + ], ), ); } diff --git a/lib/router/router.dart b/lib/router/router.dart index 070dfec6..9d996c3a 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -5,20 +5,19 @@ import 'package:ht_auth_repository/ht_auth_repository.dart'; import 'package:ht_dashboard/app/bloc/app_bloc.dart'; import 'package:ht_dashboard/app/config/config.dart' as local_config; import 'package:ht_dashboard/app/view/app_shell.dart'; +import 'package:ht_dashboard/app_configuration/view/app_configuration_page.dart'; import 'package:ht_dashboard/authentication/bloc/authentication_bloc.dart'; import 'package:ht_dashboard/authentication/view/authentication_page.dart'; import 'package:ht_dashboard/authentication/view/email_code_verification_page.dart'; import 'package:ht_dashboard/authentication/view/request_code_page.dart'; -import 'package:ht_dashboard/l10n/l10n.dart'; -import 'package:ht_dashboard/router/routes.dart'; -import 'package:ht_dashboard/app_configuration/view/app_configuration_page.dart'; import 'package:ht_dashboard/content_management/view/categories_page.dart'; import 'package:ht_dashboard/content_management/view/content_management_page.dart'; import 'package:ht_dashboard/content_management/view/headlines_page.dart'; import 'package:ht_dashboard/content_management/view/sources_page.dart'; import 'package:ht_dashboard/dashboard/view/dashboard_page.dart'; +import 'package:ht_dashboard/l10n/l10n.dart'; +import 'package:ht_dashboard/router/routes.dart'; import 'package:ht_dashboard/settings/view/settings_page.dart'; -import 'package:ht_shared/ht_shared.dart'; /// Creates and configures the GoRouter instance for the application. /// @@ -98,9 +97,10 @@ GoRouter createRouter({ name: Routes.authenticationName, builder: (BuildContext context, GoRouterState state) { final l10n = context.l10n; - const String headline = 'Sign In to Dashboard'; - const String subHeadline = 'Enter your email to get a verification code.'; - const bool showAnonymousButton = false; + const headline = 'Sign In to Dashboard'; + const subHeadline = + 'Enter your email to get a verification code.'; + const showAnonymousButton = false; return BlocProvider( create: (context) => AuthenticationBloc( diff --git a/pubspec.lock b/pubspec.lock index af182faf..9e503803 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -296,7 +296,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "9cbe2dcf84860f5be655d091b9e15bc1d48fa3dc" + resolved-ref: "096ceed8957af0935e950818b657617510b9a9ba" url: "https://github.com/headlines-toolkit/ht-shared.git" source: git version: "0.0.0"