diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 9523a25..b04b137 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -8,6 +8,7 @@ import 'package:ht_auth_repository/ht_auth_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'; +import 'package:logging/logging.dart'; part 'app_event.dart'; part 'app_state.dart'; @@ -16,11 +17,13 @@ class AppBloc extends Bloc { AppBloc({ required HtAuthRepository authenticationRepository, required HtDataRepository userAppSettingsRepository, - required HtDataRepository appConfigRepository, + required HtDataRepository appConfigRepository, required local_config.AppEnvironment environment, + Logger? logger, }) : _authenticationRepository = authenticationRepository, _userAppSettingsRepository = userAppSettingsRepository, _appConfigRepository = appConfigRepository, + _logger = logger ?? Logger('AppBloc'), super( AppState(environment: environment), ) { @@ -35,7 +38,8 @@ class AppBloc extends Bloc { final HtAuthRepository _authenticationRepository; final HtDataRepository _userAppSettingsRepository; - final HtDataRepository _appConfigRepository; + final HtDataRepository _appConfigRepository; + final Logger _logger; late final StreamSubscription _userSubscription; /// Handles user changes and loads initial settings once user is available. @@ -46,10 +50,15 @@ class AppBloc extends Bloc { final user = event.user; final AppStatus status; - if (user != null && - (user.roles.contains(UserRoles.admin) || - user.roles.contains(UserRoles.publisher))) { - status = AppStatus.authenticated; + if (user != null) { + if (user.dashboardRole == DashboardUserRole.admin || + user.dashboardRole == DashboardUserRole.publisher) { + status = AppStatus.authenticated; + } else if (user.appRole == AppUserRole.guestUser) { + status = AppStatus.anonymous; + } else { + status = AppStatus.unauthenticated; + } } else { status = AppStatus.unauthenticated; } @@ -66,20 +75,47 @@ class AppBloc extends Bloc { emit(state.copyWith(userAppSettings: userAppSettings)); } on NotFoundException { // If settings not found, create default ones - final defaultSettings = UserAppSettings(id: user.id); + _logger.info( + 'User app settings not found for user ${user.id}. Creating default.', + ); + final defaultSettings = UserAppSettings( + id: user.id, // Use actual user ID for default settings + displaySettings: const DisplaySettings( + baseTheme: AppBaseTheme.system, + accentTheme: AppAccentTheme.defaultBlue, + fontFamily: 'SystemDefault', + textScaleFactor: AppTextScaleFactor.medium, + fontWeight: AppFontWeight.regular, + ), + language: 'en', + feedPreferences: const FeedDisplayPreferences( + headlineDensity: HeadlineDensity.standard, + headlineImageStyle: HeadlineImageStyle.largeThumbnail, + showSourceInHeadlineFeed: true, + showPublishDateInHeadlineFeed: true, + ), + ); await _userAppSettingsRepository.create(item: defaultSettings); emit(state.copyWith(userAppSettings: defaultSettings)); - } on HtHttpException catch (e) { + } on HtHttpException catch (e, s) { // Handle HTTP exceptions during settings load - print('Error loading user app settings: ${e.message}'); + _logger.severe( + 'Error loading user app settings for user ${user.id}: ${e.message}', + e, + s, + ); emit(state.copyWith(clearUserAppSettings: true)); - } catch (e) { + } catch (e, s) { // Handle any other unexpected errors - print('Unexpected error loading user app settings: $e'); + _logger.severe( + 'Unexpected error loading user app settings for user ${user.id}: $e', + e, + s, + ); emit(state.copyWith(clearUserAppSettings: true)); } } else { - // If user is unauthenticated, clear app settings + // If user is unauthenticated or anonymous, clear app settings emit(state.copyWith(clearUserAppSettings: true)); } } diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index 1a6a274..186e6f4 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -15,6 +15,10 @@ enum AppStatus { anonymous, } +/// {@template app_state} +/// Represents the overall state of the application, including authentication +/// status, current user, environment, and user-specific settings. +/// {@endtemplate} class AppState extends Equatable { /// {@macro app_state} const AppState({ diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 44c6e6f..af2ec3f 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -18,44 +18,43 @@ import 'package:ht_dashboard/router/router.dart'; import 'package:ht_dashboard/shared/theme/app_theme.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'; +import 'package:ht_shared/ht_shared.dart' hide AppStatus; +import 'package:logging/logging.dart'; class App extends StatelessWidget { const App({ required HtAuthRepository htAuthenticationRepository, required HtDataRepository htHeadlinesRepository, - required HtDataRepository htCategoriesRepository, + required HtDataRepository htTopicsRepository, required HtDataRepository htCountriesRepository, required HtDataRepository htSourcesRepository, required HtDataRepository htUserAppSettingsRepository, - required HtDataRepository - htUserContentPreferencesRepository, - required HtDataRepository htAppConfigRepository, + required HtDataRepository htUserContentPreferencesRepository, + required HtDataRepository htRemoteConfigRepository, required HtDataRepository htDashboardSummaryRepository, required HtKVStorageService kvStorageService, required AppEnvironment environment, super.key, }) : _htAuthenticationRepository = htAuthenticationRepository, _htHeadlinesRepository = htHeadlinesRepository, - _htCategoriesRepository = htCategoriesRepository, + _htTopicsRepository = htTopicsRepository, _htCountriesRepository = htCountriesRepository, _htSourcesRepository = htSourcesRepository, _htUserAppSettingsRepository = htUserAppSettingsRepository, _htUserContentPreferencesRepository = htUserContentPreferencesRepository, - _htAppConfigRepository = htAppConfigRepository, + _htRemoteConfigRepository = htRemoteConfigRepository, _kvStorageService = kvStorageService, _htDashboardSummaryRepository = htDashboardSummaryRepository, _environment = environment; final HtAuthRepository _htAuthenticationRepository; final HtDataRepository _htHeadlinesRepository; - final HtDataRepository _htCategoriesRepository; + final HtDataRepository _htTopicsRepository; final HtDataRepository _htCountriesRepository; final HtDataRepository _htSourcesRepository; final HtDataRepository _htUserAppSettingsRepository; - final HtDataRepository - _htUserContentPreferencesRepository; - final HtDataRepository _htAppConfigRepository; + final HtDataRepository _htUserContentPreferencesRepository; + final HtDataRepository _htRemoteConfigRepository; final HtDataRepository _htDashboardSummaryRepository; final HtKVStorageService _kvStorageService; final AppEnvironment _environment; @@ -66,12 +65,12 @@ class App extends StatelessWidget { providers: [ RepositoryProvider.value(value: _htAuthenticationRepository), RepositoryProvider.value(value: _htHeadlinesRepository), - RepositoryProvider.value(value: _htCategoriesRepository), + RepositoryProvider.value(value: _htTopicsRepository), RepositoryProvider.value(value: _htCountriesRepository), RepositoryProvider.value(value: _htSourcesRepository), RepositoryProvider.value(value: _htUserAppSettingsRepository), RepositoryProvider.value(value: _htUserContentPreferencesRepository), - RepositoryProvider.value(value: _htAppConfigRepository), + RepositoryProvider.value(value: _htRemoteConfigRepository), RepositoryProvider.value(value: _htDashboardSummaryRepository), RepositoryProvider.value(value: _kvStorageService), ], @@ -80,10 +79,12 @@ class App extends StatelessWidget { BlocProvider( create: (context) => AppBloc( authenticationRepository: context.read(), - userAppSettingsRepository: context - .read>(), - appConfigRepository: context.read>(), + userAppSettingsRepository: + context.read>(), + appConfigRepository: + context.read>(), environment: _environment, + logger: Logger('AppBloc'), ), ), BlocProvider( @@ -93,21 +94,23 @@ class App extends StatelessWidget { ), BlocProvider( create: (context) => AppConfigurationBloc( - appConfigRepository: context.read>(), + remoteConfigRepository: + context.read>(), ), ), BlocProvider( create: (context) => ContentManagementBloc( headlinesRepository: context.read>(), - categoriesRepository: context.read>(), + topicsRepository: context.read>(), sourcesRepository: context.read>(), ), ), BlocProvider( create: (context) => DashboardBloc( - dashboardSummaryRepository: context - .read>(), - appConfigRepository: context.read>(), + dashboardSummaryRepository: + context.read>(), + appConfigRepository: + context.read>(), headlinesRepository: context.read>(), ), ), @@ -122,6 +125,7 @@ class App extends StatelessWidget { } class _AppView extends StatefulWidget { + /// {@macro app_view} const _AppView({ required this.htAuthenticationRepository, required this.environment, @@ -215,7 +219,7 @@ class _AppViewState extends State<_AppView> { themeMode: switch (baseTheme) { AppBaseTheme.light => ThemeMode.light, AppBaseTheme.dark => ThemeMode.dark, - AppBaseTheme.system || null => ThemeMode.system, + _ => ThemeMode.system, }, locale: language != null ? Locale(language) : null, ), diff --git a/lib/app/view/app_shell.dart b/lib/app/view/app_shell.dart index f5abaf6..da6d8e2 100644 --- a/lib/app/view/app_shell.dart +++ b/lib/app/view/app_shell.dart @@ -22,13 +22,6 @@ class AppShell extends StatelessWidget { /// navigators in a stateful way. final StatefulNavigationShell navigationShell; - void _goBranch(int index) { - navigationShell.goBranch( - index, - initialLocation: index == navigationShell.currentIndex, - ); - } - @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -70,7 +63,12 @@ class AppShell extends StatelessWidget { ), body: AdaptiveScaffold( selectedIndex: navigationShell.currentIndex, - onSelectedIndexChange: _goBranch, + onSelectedIndexChange: (index) { + navigationShell.goBranch( + index, + initialLocation: index == navigationShell.currentIndex, + ); + }, destinations: [ NavigationDestination( icon: const Icon(Icons.dashboard_outlined), diff --git a/lib/app_configuration/bloc/app_configuration_bloc.dart b/lib/app_configuration/bloc/app_configuration_bloc.dart index 8085e18..7c443c2 100644 --- a/lib/app_configuration/bloc/app_configuration_bloc.dart +++ b/lib/app_configuration/bloc/app_configuration_bloc.dart @@ -1,7 +1,7 @@ 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 +import 'package:ht_shared/ht_shared.dart'; // Use RemoteConfig from ht_shared part 'app_configuration_event.dart'; part 'app_configuration_state.dart'; @@ -9,8 +9,8 @@ part 'app_configuration_state.dart'; class AppConfigurationBloc extends Bloc { AppConfigurationBloc({ - required HtDataRepository appConfigRepository, - }) : _appConfigRepository = appConfigRepository, + required HtDataRepository remoteConfigRepository, + }) : _remoteConfigRepository = remoteConfigRepository, super( const AppConfigurationState(), ) { @@ -20,7 +20,7 @@ class AppConfigurationBloc on(_onAppConfigurationDiscarded); } - final HtDataRepository _appConfigRepository; + final HtDataRepository _remoteConfigRepository; Future _onAppConfigurationLoaded( AppConfigurationLoaded event, @@ -28,12 +28,12 @@ class AppConfigurationBloc ) async { emit(state.copyWith(status: AppConfigurationStatus.loading)); try { - final appConfig = await _appConfigRepository.read(id: 'app_config'); + final remoteConfig = await _remoteConfigRepository.read(id: 'app_config'); emit( state.copyWith( status: AppConfigurationStatus.success, - appConfig: appConfig, - originalAppConfig: appConfig, // Store the original config + remoteConfig: remoteConfig, + originalRemoteConfig: remoteConfig, // Store the original config isDirty: false, clearShowSaveSuccess: true, // Clear any previous success snackbar flag @@ -62,15 +62,15 @@ class AppConfigurationBloc ) async { emit(state.copyWith(status: AppConfigurationStatus.loading)); try { - final updatedConfig = await _appConfigRepository.update( - id: event.appConfig.id, - item: event.appConfig, + final updatedConfig = await _remoteConfigRepository.update( + id: event.remoteConfig.id, + item: event.remoteConfig, ); emit( state.copyWith( status: AppConfigurationStatus.success, - appConfig: updatedConfig, - originalAppConfig: updatedConfig, // Update original config on save + remoteConfig: updatedConfig, + originalRemoteConfig: updatedConfig, // Update original config on save isDirty: false, showSaveSuccess: true, // Set flag to show success snackbar ), @@ -98,7 +98,7 @@ class AppConfigurationBloc ) { emit( state.copyWith( - appConfig: event.appConfig, + remoteConfig: event.remoteConfig, isDirty: true, clearErrorMessage: true, // Clear any previous error messages clearShowSaveSuccess: true, // Clear success snackbar on field change @@ -112,7 +112,7 @@ class AppConfigurationBloc ) { emit( state.copyWith( - appConfig: state.originalAppConfig, // Revert to original config + remoteConfig: state.originalRemoteConfig, // Revert to original config isDirty: false, clearErrorMessage: true, // Clear any previous error messages clearShowSaveSuccess: true, // Clear success snackbar diff --git a/lib/app_configuration/bloc/app_configuration_event.dart b/lib/app_configuration/bloc/app_configuration_event.dart index 478f21c..0fc89bf 100644 --- a/lib/app_configuration/bloc/app_configuration_event.dart +++ b/lib/app_configuration/bloc/app_configuration_event.dart @@ -24,13 +24,13 @@ class AppConfigurationLoaded extends AppConfigurationEvent { /// {@endtemplate} class AppConfigurationUpdated extends AppConfigurationEvent { /// {@macro app_configuration_updated} - const AppConfigurationUpdated(this.appConfig); + const AppConfigurationUpdated(this.remoteConfig); /// The updated application configuration. - final AppConfig appConfig; + final RemoteConfig remoteConfig; @override - List get props => [appConfig]; + List get props => [remoteConfig]; } /// {@template app_configuration_discarded} @@ -50,12 +50,12 @@ class AppConfigurationDiscarded extends AppConfigurationEvent { class AppConfigurationFieldChanged extends AppConfigurationEvent { /// {@macro app_configuration_field_changed} const AppConfigurationFieldChanged({ - this.appConfig, + this.remoteConfig, }); - /// The partially or fully updated AppConfig object. - final AppConfig? appConfig; + /// The partially or fully updated RemoteConfig object. + final RemoteConfig? remoteConfig; @override - List get props => [appConfig]; + List get props => [remoteConfig]; } diff --git a/lib/app_configuration/bloc/app_configuration_state.dart b/lib/app_configuration/bloc/app_configuration_state.dart index 467d95e..52f6dd6 100644 --- a/lib/app_configuration/bloc/app_configuration_state.dart +++ b/lib/app_configuration/bloc/app_configuration_state.dart @@ -22,8 +22,8 @@ class AppConfigurationState extends Equatable { /// {@macro app_configuration_state} const AppConfigurationState({ this.status = AppConfigurationStatus.initial, - this.appConfig, - this.originalAppConfig, + this.remoteConfig, + this.originalRemoteConfig, this.errorMessage, this.isDirty = false, this.showSaveSuccess = false, @@ -33,10 +33,10 @@ class AppConfigurationState extends Equatable { final AppConfigurationStatus status; /// The loaded or updated application configuration. - final AppConfig? appConfig; + final RemoteConfig? remoteConfig; /// The original application configuration loaded from the backend. - final AppConfig? originalAppConfig; + final RemoteConfig? originalRemoteConfig; /// An error message if an operation failed. final String? errorMessage; @@ -50,8 +50,8 @@ class AppConfigurationState extends Equatable { /// Creates a copy of the current state with updated values. AppConfigurationState copyWith({ AppConfigurationStatus? status, - AppConfig? appConfig, - AppConfig? originalAppConfig, + RemoteConfig? remoteConfig, + RemoteConfig? originalRemoteConfig, String? errorMessage, bool? isDirty, bool clearErrorMessage = false, @@ -60,8 +60,8 @@ class AppConfigurationState extends Equatable { }) { return AppConfigurationState( status: status ?? this.status, - appConfig: appConfig ?? this.appConfig, - originalAppConfig: originalAppConfig ?? this.originalAppConfig, + remoteConfig: remoteConfig ?? this.remoteConfig, + originalRemoteConfig: originalRemoteConfig ?? this.originalRemoteConfig, errorMessage: clearErrorMessage ? null : errorMessage ?? this.errorMessage, @@ -75,8 +75,8 @@ class AppConfigurationState extends Equatable { @override List get props => [ status, - appConfig, - originalAppConfig, + remoteConfig, + originalRemoteConfig, errorMessage, isDirty, showSaveSuccess, diff --git a/lib/app_configuration/view/app_configuration_page.dart b/lib/app_configuration/view/app_configuration_page.dart index cf9cb90..c036b3a 100644 --- a/lib/app_configuration/view/app_configuration_page.dart +++ b/lib/app_configuration/view/app_configuration_page.dart @@ -4,7 +4,7 @@ import 'package:ht_dashboard/app_configuration/bloc/app_configuration_bloc.dart' import 'package:ht_dashboard/l10n/l10n.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 +import 'package:ht_shared/ht_shared.dart'; /// {@template app_configuration_page} /// A page for managing the application's remote configuration. @@ -53,8 +53,8 @@ class _AppConfigurationPageState extends State { child: Text( l10n.appConfigurationPageDescription, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), ), @@ -70,16 +70,16 @@ class _AppConfigurationPageState extends State { content: Text( l10n.appConfigSaveSuccessMessage, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onPrimary, - ), + color: Theme.of(context).colorScheme.onPrimary, + ), ), backgroundColor: Theme.of(context).colorScheme.primary, ), ); // Clear the showSaveSuccess flag after showing the snackbar context.read().add( - const AppConfigurationFieldChanged(), - ); + const AppConfigurationFieldChanged(), + ); } else if (state.status == AppConfigurationStatus.failure) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() @@ -90,8 +90,8 @@ class _AppConfigurationPageState extends State { state.errorMessage ?? l10n.unknownError, ), style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onError, - ), + color: Theme.of(context).colorScheme.onError, + ), ), backgroundColor: Theme.of(context).colorScheme.error, ), @@ -112,13 +112,13 @@ class _AppConfigurationPageState extends State { state.errorMessage ?? l10n.failedToLoadConfigurationMessage, onRetry: () { context.read().add( - const AppConfigurationLoaded(), - ); + const AppConfigurationLoaded(), + ); }, ); } else if (state.status == AppConfigurationStatus.success && - state.appConfig != null) { - final appConfig = state.appConfig!; + state.remoteConfig != null) { + final remoteConfig = state.remoteConfig!; return ListView( padding: const EdgeInsets.all(AppSpacing.lg), children: [ @@ -128,7 +128,7 @@ class _AppConfigurationPageState extends State { horizontal: AppSpacing.xxl, ), children: [ - _buildUserPreferenceLimitsSection(context, appConfig), + _buildUserPreferenceLimitsSection(context, remoteConfig), ], ), ExpansionTile( @@ -137,7 +137,7 @@ class _AppConfigurationPageState extends State { horizontal: AppSpacing.xxl, ), children: [ - _buildAdConfigSection(context, appConfig), + _buildAdConfigSection(context, remoteConfig), ], ), ExpansionTile( @@ -146,7 +146,7 @@ class _AppConfigurationPageState extends State { horizontal: AppSpacing.xxl, ), children: [ - _buildAccountActionConfigSection(context, appConfig), + _buildAccountActionConfigSection(context, remoteConfig), ], ), ExpansionTile( @@ -155,16 +155,7 @@ class _AppConfigurationPageState extends State { horizontal: AppSpacing.xxl, ), children: [ - _buildKillSwitchSection(context, appConfig), - ], - ), - ExpansionTile( - title: Text(l10n.forceUpdateTab), - childrenPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.xxl, - ), - children: [ - _buildForceUpdateSection(context, appConfig), + _buildAppStatusSection(context, remoteConfig), ], ), ], @@ -185,8 +176,8 @@ class _AppConfigurationPageState extends State { final isDirty = context.select( (AppConfigurationBloc bloc) => bloc.state.isDirty, ); - final appConfig = context.select( - (AppConfigurationBloc bloc) => bloc.state.appConfig, + final remoteConfig = context.select( + (AppConfigurationBloc bloc) => bloc.state.remoteConfig, ); return BottomAppBar( @@ -200,8 +191,8 @@ class _AppConfigurationPageState extends State { ? () { // Discard changes: revert to original config context.read().add( - const AppConfigurationDiscarded(), - ); + const AppConfigurationDiscarded(), + ); } : null, child: Text(context.l10n.discardChangesButton), @@ -211,10 +202,10 @@ class _AppConfigurationPageState extends State { onPressed: isDirty ? () async { final confirmed = await _showConfirmationDialog(context); - if (context.mounted && confirmed && appConfig != null) { + if (context.mounted && confirmed && remoteConfig != null) { context.read().add( - AppConfigurationUpdated(appConfig), - ); + AppConfigurationUpdated(remoteConfig), + ); } } : null, @@ -263,7 +254,7 @@ class _AppConfigurationPageState extends State { Widget _buildUserPreferenceLimitsSection( BuildContext context, - AppConfig appConfig, + RemoteConfig remoteConfig, ) { final l10n = context.l10n; return Column( @@ -272,8 +263,8 @@ class _AppConfigurationPageState extends State { Text( l10n.userContentLimitsDescription, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - ), + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), ), const SizedBox(height: AppSpacing.lg), ExpansionTile( @@ -283,14 +274,14 @@ class _AppConfigurationPageState extends State { ), children: [ _UserPreferenceLimitsForm( - userRole: UserRoles.guestUser, - appConfig: appConfig, + userRole: AppUserRole.guestUser, + remoteConfig: remoteConfig, onConfigChanged: (newConfig) { context.read().add( - AppConfigurationFieldChanged( - appConfig: newConfig, - ), - ); + AppConfigurationFieldChanged( + remoteConfig: newConfig, + ), + ); }, buildIntField: _buildIntField, ), @@ -303,14 +294,14 @@ class _AppConfigurationPageState extends State { ), children: [ _UserPreferenceLimitsForm( - userRole: UserRoles.standardUser, - appConfig: appConfig, + userRole: AppUserRole.standardUser, + remoteConfig: remoteConfig, onConfigChanged: (newConfig) { context.read().add( - AppConfigurationFieldChanged( - appConfig: newConfig, - ), - ); + AppConfigurationFieldChanged( + remoteConfig: newConfig, + ), + ); }, buildIntField: _buildIntField, ), @@ -323,14 +314,14 @@ class _AppConfigurationPageState extends State { ), children: [ _UserPreferenceLimitsForm( - userRole: UserRoles.premiumUser, - appConfig: appConfig, + userRole: AppUserRole.premiumUser, + remoteConfig: remoteConfig, onConfigChanged: (newConfig) { context.read().add( - AppConfigurationFieldChanged( - appConfig: newConfig, - ), - ); + AppConfigurationFieldChanged( + remoteConfig: newConfig, + ), + ); }, buildIntField: _buildIntField, ), @@ -340,7 +331,7 @@ class _AppConfigurationPageState extends State { ); } - Widget _buildAdConfigSection(BuildContext context, AppConfig appConfig) { + Widget _buildAdConfigSection(BuildContext context, RemoteConfig remoteConfig) { final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -348,8 +339,8 @@ class _AppConfigurationPageState extends State { Text( l10n.adSettingsDescription, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - ), + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), ), const SizedBox(height: AppSpacing.lg), ExpansionTile( @@ -359,14 +350,14 @@ class _AppConfigurationPageState extends State { ), children: [ _AdConfigForm( - userRole: UserRoles.guestUser, - appConfig: appConfig, + userRole: AppUserRole.guestUser, + remoteConfig: remoteConfig, onConfigChanged: (newConfig) { context.read().add( - AppConfigurationFieldChanged( - appConfig: newConfig, - ), - ); + AppConfigurationFieldChanged( + remoteConfig: newConfig, + ), + ); }, buildIntField: _buildIntField, ), @@ -379,14 +370,14 @@ class _AppConfigurationPageState extends State { ), children: [ _AdConfigForm( - userRole: UserRoles.standardUser, - appConfig: appConfig, + userRole: AppUserRole.standardUser, + remoteConfig: remoteConfig, onConfigChanged: (newConfig) { context.read().add( - AppConfigurationFieldChanged( - appConfig: newConfig, - ), - ); + AppConfigurationFieldChanged( + remoteConfig: newConfig, + ), + ); }, buildIntField: _buildIntField, ), @@ -399,14 +390,14 @@ class _AppConfigurationPageState extends State { ), children: [ _AdConfigForm( - userRole: UserRoles.premiumUser, - appConfig: appConfig, + userRole: AppUserRole.premiumUser, + remoteConfig: remoteConfig, onConfigChanged: (newConfig) { context.read().add( - AppConfigurationFieldChanged( - appConfig: newConfig, - ), - ); + AppConfigurationFieldChanged( + remoteConfig: newConfig, + ), + ); }, buildIntField: _buildIntField, ), @@ -418,7 +409,7 @@ class _AppConfigurationPageState extends State { Widget _buildAccountActionConfigSection( BuildContext context, - AppConfig appConfig, + RemoteConfig remoteConfig, ) { final l10n = context.l10n; return Column( @@ -427,8 +418,8 @@ class _AppConfigurationPageState extends State { Text( l10n.inAppPromptsDescription, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - ), + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), ), const SizedBox(height: AppSpacing.lg), ExpansionTile( @@ -438,14 +429,14 @@ class _AppConfigurationPageState extends State { ), children: [ _AccountActionConfigForm( - userRole: UserRoles.guestUser, - appConfig: appConfig, + userRole: AppUserRole.guestUser, + remoteConfig: remoteConfig, onConfigChanged: (newConfig) { context.read().add( - AppConfigurationFieldChanged( - appConfig: newConfig, - ), - ); + AppConfigurationFieldChanged( + remoteConfig: newConfig, + ), + ); }, buildIntField: _buildIntField, ), @@ -458,14 +449,14 @@ class _AppConfigurationPageState extends State { ), children: [ _AccountActionConfigForm( - userRole: UserRoles.standardUser, - appConfig: appConfig, + userRole: AppUserRole.standardUser, + remoteConfig: remoteConfig, onConfigChanged: (newConfig) { context.read().add( - AppConfigurationFieldChanged( - appConfig: newConfig, - ), - ); + AppConfigurationFieldChanged( + remoteConfig: newConfig, + ), + ); }, buildIntField: _buildIntField, ), @@ -475,7 +466,8 @@ class _AppConfigurationPageState extends State { ); } - Widget _buildKillSwitchSection(BuildContext context, AppConfig appConfig) { + Widget _buildAppStatusSection( + BuildContext context, RemoteConfig remoteConfig) { final l10n = context.l10n; return SingleChildScrollView( padding: const EdgeInsets.all(AppSpacing.lg), @@ -485,151 +477,92 @@ class _AppConfigurationPageState extends State { Text( l10n.appOperationalStatusWarning, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.error, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: AppSpacing.lg), - _buildDropdownField( - context, - label: l10n.appOperationalStatusLabel, - description: l10n.appOperationalStatusDescription, - value: appConfig.appOperationalStatus, - items: RemoteAppStatus.values, - itemLabelBuilder: (status) => status.name, - onChanged: (value) { - if (value != null) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith(appOperationalStatus: value), - ), - ); - } - }, - ), - if (appConfig.appOperationalStatus == RemoteAppStatus.maintenance) - _buildTextField( - context, - label: l10n.maintenanceMessageLabel, - description: l10n.maintenanceMessageDescription, - value: appConfig.maintenanceMessage, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith(maintenanceMessage: value), - ), - ); - }, - ), - if (appConfig.appOperationalStatus == RemoteAppStatus.disabled) - _buildTextField( - context, - label: l10n.disabledMessageLabel, - description: l10n.disabledMessageDescription, - value: appConfig.disabledMessage, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith(disabledMessage: value), - ), - ); - }, - ), - ], - ), - ); - } - - Widget _buildForceUpdateSection(BuildContext context, AppConfig appConfig) { - final l10n = context.l10n; - return SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.forceUpdateDescription, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - ), + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.bold, + ), ), const SizedBox(height: AppSpacing.lg), - _buildTextField( - context, - label: l10n.minAllowedAppVersionLabel, - description: l10n.minAllowedAppVersionDescription, - value: appConfig.minAllowedAppVersion, + SwitchListTile( + title: Text(l10n.isUnderMaintenanceLabel), + subtitle: Text(l10n.isUnderMaintenanceDescription), + value: remoteConfig.appStatus.isUnderMaintenance, onChanged: (value) { context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith(minAllowedAppVersion: value), - ), - ); + AppConfigurationFieldChanged( + remoteConfig: remoteConfig.copyWith( + appStatus: remoteConfig.appStatus.copyWith( + isUnderMaintenance: value, + ), + ), + ), + ); }, ), _buildTextField( context, label: l10n.latestAppVersionLabel, description: l10n.latestAppVersionDescription, - value: appConfig.latestAppVersion, + value: remoteConfig.appStatus.latestAppVersion, onChanged: (value) { context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith(latestAppVersion: value), - ), - ); - }, - ), - _buildTextField( - context, - label: l10n.updateRequiredMessageLabel, - description: l10n.updateRequiredMessageDescription, - value: appConfig.updateRequiredMessage, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith(updateRequiredMessage: value), - ), - ); + AppConfigurationFieldChanged( + remoteConfig: remoteConfig.copyWith( + appStatus: remoteConfig.appStatus.copyWith( + latestAppVersion: value, + ), + ), + ), + ); }, ), - _buildTextField( - context, - label: l10n.updateOptionalMessageLabel, - description: l10n.updateOptionalMessageDescription, - value: appConfig.updateOptionalMessage, + SwitchListTile( + title: Text(l10n.isLatestVersionOnlyLabel), + subtitle: Text(l10n.isLatestVersionOnlyDescription), + value: remoteConfig.appStatus.isLatestVersionOnly, onChanged: (value) { context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith(updateOptionalMessage: value), - ), - ); + AppConfigurationFieldChanged( + remoteConfig: remoteConfig.copyWith( + appStatus: remoteConfig.appStatus.copyWith( + isLatestVersionOnly: value, + ), + ), + ), + ); }, ), _buildTextField( context, - label: l10n.iosStoreUrlLabel, - description: l10n.iosStoreUrlDescription, - value: appConfig.iosStoreUrl, + label: l10n.iosUpdateUrlLabel, + description: l10n.iosUpdateUrlDescription, + value: remoteConfig.appStatus.iosUpdateUrl, onChanged: (value) { context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith(iosStoreUrl: value), - ), - ); + AppConfigurationFieldChanged( + remoteConfig: remoteConfig.copyWith( + appStatus: remoteConfig.appStatus.copyWith( + iosUpdateUrl: value, + ), + ), + ), + ); }, ), _buildTextField( context, - label: l10n.androidStoreUrlLabel, - description: l10n.androidStoreUrlDescription, - value: appConfig.androidStoreUrl, + label: l10n.androidUpdateUrlLabel, + description: l10n.androidUpdateUrlDescription, + value: remoteConfig.appStatus.androidUpdateUrl, onChanged: (value) { context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith(androidStoreUrl: value), - ), - ); + AppConfigurationFieldChanged( + remoteConfig: remoteConfig.copyWith( + appStatus: remoteConfig.appStatus.copyWith( + androidUpdateUrl: value, + ), + ), + ), + ); }, ), ], @@ -643,7 +576,7 @@ class _AppConfigurationPageState extends State { required String description, required int value, required ValueChanged onChanged, - TextEditingController? controller, // Add controller parameter + TextEditingController? controller, }) { return Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), @@ -658,15 +591,14 @@ class _AppConfigurationPageState extends State { Text( description, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - ), + color: + Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), ), const SizedBox(height: AppSpacing.xs), TextFormField( - controller: controller, // Use controller - initialValue: controller == null - ? value.toString() - : null, // Only use initialValue if no controller + controller: controller, + initialValue: controller == null ? value.toString() : null, keyboardType: TextInputType.number, decoration: const InputDecoration( border: OutlineInputBorder(), @@ -690,7 +622,7 @@ class _AppConfigurationPageState extends State { required String description, required String? value, required ValueChanged onChanged, - TextEditingController? controller, // Add controller parameter + TextEditingController? controller, }) { return Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), @@ -705,64 +637,18 @@ class _AppConfigurationPageState extends State { Text( description, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - ), + color: + Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), ), const SizedBox(height: AppSpacing.xs), TextFormField( - controller: controller, // Use controller - initialValue: controller == null - ? value - : null, // Only use initialValue if no controller - decoration: const InputDecoration( - border: OutlineInputBorder(), - isDense: true, - ), - onChanged: onChanged, - ), - ], - ), - ); - } - - 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, + controller: controller, + initialValue: controller == null ? value : null, decoration: const InputDecoration( border: OutlineInputBorder(), isDense: true, ), - items: items.map((item) { - return DropdownMenuItem( - value: item, - child: Text(itemLabelBuilder(item)), - ); - }).toList(), onChanged: onChanged, ), ], @@ -774,14 +660,14 @@ class _AppConfigurationPageState extends State { class _UserPreferenceLimitsForm extends StatefulWidget { const _UserPreferenceLimitsForm({ required this.userRole, - required this.appConfig, + required this.remoteConfig, required this.onConfigChanged, required this.buildIntField, }); - final String userRole; - final AppConfig appConfig; - final ValueChanged onConfigChanged; + final AppUserRole userRole; + final RemoteConfig remoteConfig; + final ValueChanged onConfigChanged; final Widget Function( BuildContext context, { required String label, @@ -789,8 +675,7 @@ class _UserPreferenceLimitsForm extends StatefulWidget { required int value, required ValueChanged onChanged, TextEditingController? controller, - }) - buildIntField; + }) buildIntField; @override State<_UserPreferenceLimitsForm> createState() => @@ -798,269 +683,159 @@ class _UserPreferenceLimitsForm extends StatefulWidget { } class _UserPreferenceLimitsFormState extends State<_UserPreferenceLimitsForm> { - late final TextEditingController _guestFollowedItemsLimitController; - late final TextEditingController _guestSavedHeadlinesLimitController; - late final TextEditingController _authenticatedFollowedItemsLimitController; - late final TextEditingController _authenticatedSavedHeadlinesLimitController; - late final TextEditingController _premiumFollowedItemsLimitController; - late final TextEditingController _premiumSavedHeadlinesLimitController; + late final TextEditingController _followedItemsLimitController; + late final TextEditingController _savedHeadlinesLimitController; @override void initState() { super.initState(); - _guestFollowedItemsLimitController = TextEditingController( - text: widget.appConfig.userPreferenceLimits.guestFollowedItemsLimit - .toString(), - ); - _guestSavedHeadlinesLimitController = TextEditingController( - text: widget.appConfig.userPreferenceLimits.guestSavedHeadlinesLimit - .toString(), - ); - _authenticatedFollowedItemsLimitController = TextEditingController( - text: widget - .appConfig - .userPreferenceLimits - .authenticatedFollowedItemsLimit - .toString(), - ); - _authenticatedSavedHeadlinesLimitController = TextEditingController( - text: widget - .appConfig - .userPreferenceLimits - .authenticatedSavedHeadlinesLimit - .toString(), - ); - _premiumFollowedItemsLimitController = TextEditingController( - text: widget.appConfig.userPreferenceLimits.premiumFollowedItemsLimit - .toString(), - ); - _premiumSavedHeadlinesLimitController = TextEditingController( - text: widget.appConfig.userPreferenceLimits.premiumSavedHeadlinesLimit - .toString(), - ); + _initializeControllers(); } @override void didUpdateWidget(covariant _UserPreferenceLimitsForm oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.appConfig.userPreferenceLimits != - oldWidget.appConfig.userPreferenceLimits) { - _guestFollowedItemsLimitController.value = TextEditingValue( - text: widget.appConfig.userPreferenceLimits.guestFollowedItemsLimit - .toString(), - selection: TextSelection.collapsed( - offset: widget.appConfig.userPreferenceLimits.guestFollowedItemsLimit - .toString() - .length, - ), - ); - _guestSavedHeadlinesLimitController.value = TextEditingValue( - text: widget.appConfig.userPreferenceLimits.guestSavedHeadlinesLimit - .toString(), - selection: TextSelection.collapsed( - offset: widget.appConfig.userPreferenceLimits.guestSavedHeadlinesLimit - .toString() - .length, - ), - ); - _authenticatedFollowedItemsLimitController.value = TextEditingValue( - text: widget - .appConfig - .userPreferenceLimits - .authenticatedFollowedItemsLimit - .toString(), - selection: TextSelection.collapsed( - offset: widget - .appConfig - .userPreferenceLimits - .authenticatedFollowedItemsLimit - .toString() - .length, - ), - ); - _authenticatedSavedHeadlinesLimitController.value = TextEditingValue( - text: widget - .appConfig - .userPreferenceLimits - .authenticatedSavedHeadlinesLimit - .toString(), - selection: TextSelection.collapsed( - offset: widget - .appConfig - .userPreferenceLimits - .authenticatedSavedHeadlinesLimit - .toString() - .length, - ), - ); - _premiumFollowedItemsLimitController.value = TextEditingValue( - text: widget.appConfig.userPreferenceLimits.premiumFollowedItemsLimit - .toString(), - selection: TextSelection.collapsed( - offset: widget - .appConfig - .userPreferenceLimits - .premiumFollowedItemsLimit - .toString() - .length, - ), - ); - _premiumSavedHeadlinesLimitController.value = TextEditingValue( - text: widget.appConfig.userPreferenceLimits.premiumSavedHeadlinesLimit - .toString(), - selection: TextSelection.collapsed( - offset: widget - .appConfig - .userPreferenceLimits - .premiumSavedHeadlinesLimit - .toString() - .length, - ), - ); + if (widget.remoteConfig.userPreferenceConfig != + oldWidget.remoteConfig.userPreferenceConfig) { + _updateControllers(); + } + } + + void _initializeControllers() { + final config = widget.remoteConfig.userPreferenceConfig; + switch (widget.userRole) { + case AppUserRole.guestUser: + _followedItemsLimitController = + TextEditingController(text: config.guestFollowedItemsLimit.toString()); + _savedHeadlinesLimitController = + TextEditingController(text: config.guestSavedHeadlinesLimit.toString()); + case AppUserRole.standardUser: + _followedItemsLimitController = TextEditingController( + text: config.authenticatedFollowedItemsLimit.toString()); + _savedHeadlinesLimitController = TextEditingController( + text: config.authenticatedSavedHeadlinesLimit.toString()); + case AppUserRole.premiumUser: + _followedItemsLimitController = TextEditingController( + text: config.premiumFollowedItemsLimit.toString()); + _savedHeadlinesLimitController = TextEditingController( + text: config.premiumSavedHeadlinesLimit.toString()); + } + } + + void _updateControllers() { + final config = widget.remoteConfig.userPreferenceConfig; + switch (widget.userRole) { + case AppUserRole.guestUser: + _followedItemsLimitController.text = + config.guestFollowedItemsLimit.toString(); + _savedHeadlinesLimitController.text = + config.guestSavedHeadlinesLimit.toString(); + case AppUserRole.standardUser: + _followedItemsLimitController.text = + config.authenticatedFollowedItemsLimit.toString(); + _savedHeadlinesLimitController.text = + config.authenticatedSavedHeadlinesLimit.toString(); + case AppUserRole.premiumUser: + _followedItemsLimitController.text = + config.premiumFollowedItemsLimit.toString(); + _savedHeadlinesLimitController.text = + config.premiumSavedHeadlinesLimit.toString(); } } @override void dispose() { - _guestFollowedItemsLimitController.dispose(); - _guestSavedHeadlinesLimitController.dispose(); - _authenticatedFollowedItemsLimitController.dispose(); - _authenticatedSavedHeadlinesLimitController.dispose(); - _premiumFollowedItemsLimitController.dispose(); - _premiumSavedHeadlinesLimitController.dispose(); + _followedItemsLimitController.dispose(); + _savedHeadlinesLimitController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final userPreferenceLimits = widget.appConfig.userPreferenceLimits; + final userPreferenceConfig = widget.remoteConfig.userPreferenceConfig; + return Column( + children: [ + widget.buildIntField( + context, + label: 'Followed Items Limit', + description: + 'Maximum number of countries, news sources, or categories this ' + 'user role can follow (each type has its own limit).', + value: _getFollowedItemsLimit(userPreferenceConfig), + onChanged: (value) { + widget.onConfigChanged( + widget.remoteConfig.copyWith( + userPreferenceConfig: + _updateFollowedItemsLimit(userPreferenceConfig, value), + ), + ); + }, + controller: _followedItemsLimitController, + ), + widget.buildIntField( + context, + label: 'Saved Headlines Limit', + description: + 'Maximum number of headlines this user role can save.', + value: _getSavedHeadlinesLimit(userPreferenceConfig), + onChanged: (value) { + widget.onConfigChanged( + widget.remoteConfig.copyWith( + userPreferenceConfig: + _updateSavedHeadlinesLimit(userPreferenceConfig, value), + ), + ); + }, + controller: _savedHeadlinesLimitController, + ), + ], + ); + } + + int _getFollowedItemsLimit(UserPreferenceConfig config) { switch (widget.userRole) { - case UserRoles.guestUser: - return Column( - children: [ - widget.buildIntField( - context, - label: 'Guest Followed Items Limit', - description: - 'Maximum number of countries, news sources, or categories a ' - 'Guest user can follow (each type has its own limit).', - value: userPreferenceLimits.guestFollowedItemsLimit, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - userPreferenceLimits: userPreferenceLimits.copyWith( - guestFollowedItemsLimit: value, - ), - ), - ); - }, - controller: _guestFollowedItemsLimitController, - ), - widget.buildIntField( - context, - label: 'Guest Saved Headlines Limit', - description: 'Maximum number of headlines a Guest user can save.', - value: userPreferenceLimits.guestSavedHeadlinesLimit, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - userPreferenceLimits: userPreferenceLimits.copyWith( - guestSavedHeadlinesLimit: value, - ), - ), - ); - }, - controller: _guestSavedHeadlinesLimitController, - ), - ], - ); - case UserRoles.standardUser: - return Column( - children: [ - widget.buildIntField( - context, - label: 'Standard User Followed Items Limit', - description: - 'Maximum number of countries, news sources, or categories a ' - 'Standard user can follow (each type has its own limit).', - value: userPreferenceLimits.authenticatedFollowedItemsLimit, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - userPreferenceLimits: userPreferenceLimits.copyWith( - authenticatedFollowedItemsLimit: value, - ), - ), - ); - }, - controller: _authenticatedFollowedItemsLimitController, - ), - widget.buildIntField( - context, - label: 'Standard User Saved Headlines Limit', - description: - 'Maximum number of headlines a Standard user can save.', - value: userPreferenceLimits.authenticatedSavedHeadlinesLimit, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - userPreferenceLimits: userPreferenceLimits.copyWith( - authenticatedSavedHeadlinesLimit: value, - ), - ), - ); - }, - controller: _authenticatedSavedHeadlinesLimitController, - ), - ], - ); - case UserRoles.premiumUser: - return Column( - children: [ - widget.buildIntField( - context, - label: 'Premium Followed Items Limit', - description: - 'Maximum number of countries, news sources, or categories a ' - 'Premium user can follow (each type has its own limit).', - value: userPreferenceLimits.premiumFollowedItemsLimit, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - userPreferenceLimits: userPreferenceLimits.copyWith( - premiumFollowedItemsLimit: value, - ), - ), - ); - }, - controller: _premiumFollowedItemsLimitController, - ), - widget.buildIntField( - context, - label: 'Premium Saved Headlines Limit', - description: - 'Maximum number of headlines a Premium user can save.', - value: userPreferenceLimits.premiumSavedHeadlinesLimit, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - userPreferenceLimits: userPreferenceLimits.copyWith( - premiumSavedHeadlinesLimit: value, - ), - ), - ); - }, - controller: _premiumSavedHeadlinesLimitController, - ), - ], - ); - case UserRoles.admin: - // Admin role might not have specific limits here, or could be - // a separate configuration. For now, return empty. - return const SizedBox.shrink(); - default: - return const SizedBox.shrink(); + case AppUserRole.guestUser: + return config.guestFollowedItemsLimit; + case AppUserRole.standardUser: + return config.authenticatedFollowedItemsLimit; + case AppUserRole.premiumUser: + return config.premiumFollowedItemsLimit; + } + } + + int _getSavedHeadlinesLimit(UserPreferenceConfig config) { + switch (widget.userRole) { + case AppUserRole.guestUser: + return config.guestSavedHeadlinesLimit; + case AppUserRole.standardUser: + return config.authenticatedSavedHeadlinesLimit; + case AppUserRole.premiumUser: + return config.premiumSavedHeadlinesLimit; + } + } + + UserPreferenceConfig _updateFollowedItemsLimit( + UserPreferenceConfig config, int value) { + switch (widget.userRole) { + case AppUserRole.guestUser: + return config.copyWith(guestFollowedItemsLimit: value); + case AppUserRole.standardUser: + return config.copyWith(authenticatedFollowedItemsLimit: value); + case AppUserRole.premiumUser: + return config.copyWith(premiumFollowedItemsLimit: value); + } + } + + UserPreferenceConfig _updateSavedHeadlinesLimit( + UserPreferenceConfig config, int value) { + switch (widget.userRole) { + case AppUserRole.guestUser: + return config.copyWith(guestSavedHeadlinesLimit: value); + case AppUserRole.standardUser: + return config.copyWith(authenticatedSavedHeadlinesLimit: value); + case AppUserRole.premiumUser: + return config.copyWith(premiumSavedHeadlinesLimit: value); } } } @@ -1068,14 +843,14 @@ class _UserPreferenceLimitsFormState extends State<_UserPreferenceLimitsForm> { class _AdConfigForm extends StatefulWidget { const _AdConfigForm({ required this.userRole, - required this.appConfig, + required this.remoteConfig, required this.onConfigChanged, required this.buildIntField, }); - final String userRole; - final AppConfig appConfig; - final ValueChanged onConfigChanged; + final AppUserRole userRole; + final RemoteConfig remoteConfig; + final ValueChanged onConfigChanged; final Widget Function( BuildContext context, { required String label, @@ -1083,379 +858,227 @@ class _AdConfigForm extends StatefulWidget { required int value, required ValueChanged onChanged, TextEditingController? controller, - }) - buildIntField; + }) buildIntField; @override State<_AdConfigForm> createState() => _AdConfigFormState(); } class _AdConfigFormState extends State<_AdConfigForm> { - late final TextEditingController _guestAdFrequencyController; - late final TextEditingController _guestAdPlacementIntervalController; - late final TextEditingController - _guestArticlesToReadBeforeShowingInterstitialAdsController; - late final TextEditingController _authenticatedAdFrequencyController; - late final TextEditingController _authenticatedAdPlacementIntervalController; + late final TextEditingController _adFrequencyController; + late final TextEditingController _adPlacementIntervalController; late final TextEditingController - _standardUserArticlesToReadBeforeShowingInterstitialAdsController; - late final TextEditingController _premiumAdFrequencyController; - late final TextEditingController _premiumAdPlacementIntervalController; - late final TextEditingController - _premiumUserArticlesToReadBeforeShowingInterstitialAdsController; + _articlesToReadBeforeShowingInterstitialAdsController; @override void initState() { super.initState(); - _guestAdFrequencyController = TextEditingController( - text: widget.appConfig.adConfig.guestAdFrequency.toString(), - ); - _guestAdPlacementIntervalController = TextEditingController( - text: widget.appConfig.adConfig.guestAdPlacementInterval.toString(), - ); - _guestArticlesToReadBeforeShowingInterstitialAdsController = - TextEditingController( - text: widget - .appConfig - .adConfig - .guestArticlesToReadBeforeShowingInterstitialAds - .toString(), - ); - _authenticatedAdFrequencyController = TextEditingController( - text: widget.appConfig.adConfig.authenticatedAdFrequency.toString(), - ); - _authenticatedAdPlacementIntervalController = TextEditingController( - text: widget.appConfig.adConfig.authenticatedAdPlacementInterval - .toString(), - ); - _standardUserArticlesToReadBeforeShowingInterstitialAdsController = - TextEditingController( - text: widget - .appConfig - .adConfig - .standardUserArticlesToReadBeforeShowingInterstitialAds - .toString(), - ); - _premiumAdFrequencyController = TextEditingController( - text: widget.appConfig.adConfig.premiumAdFrequency.toString(), - ); - _premiumAdPlacementIntervalController = TextEditingController( - text: widget.appConfig.adConfig.premiumAdPlacementInterval.toString(), - ); - _premiumUserArticlesToReadBeforeShowingInterstitialAdsController = - TextEditingController( - text: widget - .appConfig - .adConfig - .premiumUserArticlesToReadBeforeShowingInterstitialAds - .toString(), - ); + _initializeControllers(); } @override void didUpdateWidget(covariant _AdConfigForm oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.appConfig.adConfig != oldWidget.appConfig.adConfig) { - _guestAdFrequencyController.value = TextEditingValue( - text: widget.appConfig.adConfig.guestAdFrequency.toString(), - selection: TextSelection.collapsed( - offset: widget.appConfig.adConfig.guestAdFrequency.toString().length, - ), - ); - _guestAdPlacementIntervalController.value = TextEditingValue( - text: widget.appConfig.adConfig.guestAdPlacementInterval.toString(), - selection: TextSelection.collapsed( - offset: widget.appConfig.adConfig.guestAdPlacementInterval - .toString() - .length, - ), - ); - _guestArticlesToReadBeforeShowingInterstitialAdsController.value = - TextEditingValue( - text: widget - .appConfig - .adConfig - .guestArticlesToReadBeforeShowingInterstitialAds - .toString(), - selection: TextSelection.collapsed( - offset: widget - .appConfig - .adConfig - .guestArticlesToReadBeforeShowingInterstitialAds - .toString() - .length, - ), - ); - _authenticatedAdFrequencyController.value = TextEditingValue( - text: widget.appConfig.adConfig.authenticatedAdFrequency.toString(), - selection: TextSelection.collapsed( - offset: widget.appConfig.adConfig.authenticatedAdFrequency - .toString() - .length, - ), - ); - _authenticatedAdPlacementIntervalController.value = TextEditingValue( - text: widget.appConfig.adConfig.authenticatedAdPlacementInterval - .toString(), - selection: TextSelection.collapsed( - offset: widget.appConfig.adConfig.authenticatedAdPlacementInterval - .toString() - .length, - ), - ); - _standardUserArticlesToReadBeforeShowingInterstitialAdsController.value = - TextEditingValue( - text: widget - .appConfig - .adConfig - .standardUserArticlesToReadBeforeShowingInterstitialAds - .toString(), - selection: TextSelection.collapsed( - offset: widget - .appConfig - .adConfig - .standardUserArticlesToReadBeforeShowingInterstitialAds - .toString() - .length, - ), - ); - _premiumAdFrequencyController.value = TextEditingValue( - text: widget.appConfig.adConfig.premiumAdFrequency.toString(), - selection: TextSelection.collapsed( - offset: widget.appConfig.adConfig.premiumAdFrequency - .toString() - .length, - ), - ); - _premiumAdPlacementIntervalController.value = TextEditingValue( - text: widget.appConfig.adConfig.premiumAdPlacementInterval.toString(), - selection: TextSelection.collapsed( - offset: widget.appConfig.adConfig.premiumAdPlacementInterval - .toString() - .length, - ), - ); - _premiumUserArticlesToReadBeforeShowingInterstitialAdsController.value = - TextEditingValue( - text: widget - .appConfig - .adConfig - .premiumUserArticlesToReadBeforeShowingInterstitialAds - .toString(), - selection: TextSelection.collapsed( - offset: widget - .appConfig - .adConfig - .premiumUserArticlesToReadBeforeShowingInterstitialAds - .toString() - .length, - ), - ); + if (widget.remoteConfig.adConfig != oldWidget.remoteConfig.adConfig) { + _updateControllers(); + } + } + + void _initializeControllers() { + final adConfig = widget.remoteConfig.adConfig; + switch (widget.userRole) { + case AppUserRole.guestUser: + _adFrequencyController = + TextEditingController(text: adConfig.guestAdFrequency.toString()); + _adPlacementIntervalController = TextEditingController( + text: adConfig.guestAdPlacementInterval.toString()); + _articlesToReadBeforeShowingInterstitialAdsController = + TextEditingController( + text: adConfig.guestArticlesToReadBeforeShowingInterstitialAds + .toString()); + case AppUserRole.standardUser: + _adFrequencyController = TextEditingController( + text: adConfig.authenticatedAdFrequency.toString()); + _adPlacementIntervalController = TextEditingController( + text: adConfig.authenticatedAdPlacementInterval.toString()); + _articlesToReadBeforeShowingInterstitialAdsController = + TextEditingController( + text: adConfig + .standardUserArticlesToReadBeforeShowingInterstitialAds + .toString()); + case AppUserRole.premiumUser: + _adFrequencyController = + TextEditingController(text: adConfig.premiumAdFrequency.toString()); + _adPlacementIntervalController = TextEditingController( + text: adConfig.premiumAdPlacementInterval.toString()); + _articlesToReadBeforeShowingInterstitialAdsController = + TextEditingController( + text: adConfig + .premiumUserArticlesToReadBeforeShowingInterstitialAds + .toString()); + } + } + + void _updateControllers() { + final adConfig = widget.remoteConfig.adConfig; + switch (widget.userRole) { + case AppUserRole.guestUser: + _adFrequencyController.text = adConfig.guestAdFrequency.toString(); + _adPlacementIntervalController.text = + adConfig.guestAdPlacementInterval.toString(); + _articlesToReadBeforeShowingInterstitialAdsController.text = adConfig + .guestArticlesToReadBeforeShowingInterstitialAds + .toString(); + case AppUserRole.standardUser: + _adFrequencyController.text = + adConfig.authenticatedAdFrequency.toString(); + _adPlacementIntervalController.text = + adConfig.authenticatedAdPlacementInterval.toString(); + _articlesToReadBeforeShowingInterstitialAdsController.text = adConfig + .standardUserArticlesToReadBeforeShowingInterstitialAds + .toString(); + case AppUserRole.premiumUser: + _adFrequencyController.text = adConfig.premiumAdFrequency.toString(); + _adPlacementIntervalController.text = + adConfig.premiumAdPlacementInterval.toString(); + _articlesToReadBeforeShowingInterstitialAdsController.text = adConfig + .premiumUserArticlesToReadBeforeShowingInterstitialAds + .toString(); } } @override void dispose() { - _guestAdFrequencyController.dispose(); - _guestAdPlacementIntervalController.dispose(); - _guestArticlesToReadBeforeShowingInterstitialAdsController.dispose(); - _authenticatedAdFrequencyController.dispose(); - _authenticatedAdPlacementIntervalController.dispose(); - _standardUserArticlesToReadBeforeShowingInterstitialAdsController.dispose(); - _premiumAdFrequencyController.dispose(); - _premiumAdPlacementIntervalController.dispose(); - _premiumUserArticlesToReadBeforeShowingInterstitialAdsController.dispose(); + _adFrequencyController.dispose(); + _adPlacementIntervalController.dispose(); + _articlesToReadBeforeShowingInterstitialAdsController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final adConfig = widget.appConfig.adConfig; + final adConfig = widget.remoteConfig.adConfig; + return Column( + children: [ + widget.buildIntField( + context, + label: 'Ad Frequency', + description: + 'How often an ad can appear for this user role (e.g., a value ' + 'of 5 means an ad could be placed after every 5 news items).', + value: _getAdFrequency(adConfig), + onChanged: (value) { + widget.onConfigChanged( + widget.remoteConfig.copyWith( + adConfig: _updateAdFrequency(adConfig, value), + ), + ); + }, + controller: _adFrequencyController, + ), + widget.buildIntField( + context, + label: 'Ad Placement Interval', + description: + 'Minimum number of news items that must be shown before the ' + 'very first ad appears for this user role.', + value: _getAdPlacementInterval(adConfig), + onChanged: (value) { + widget.onConfigChanged( + widget.remoteConfig.copyWith( + adConfig: _updateAdPlacementInterval(adConfig, value), + ), + ); + }, + controller: _adPlacementIntervalController, + ), + widget.buildIntField( + context, + label: 'Articles Before Interstitial Ads', + description: + 'Number of articles this user role needs to read before a ' + 'full-screen interstitial ad is shown.', + value: _getArticlesBeforeInterstitial(adConfig), + onChanged: (value) { + widget.onConfigChanged( + widget.remoteConfig.copyWith( + adConfig: _updateArticlesBeforeInterstitial(adConfig, value), + ), + ); + }, + controller: _articlesToReadBeforeShowingInterstitialAdsController, + ), + ], + ); + } + + int _getAdFrequency(AdConfig config) { switch (widget.userRole) { - case UserRoles.guestUser: - return Column( - children: [ - widget.buildIntField( - context, - label: 'Guest Ad Frequency', - description: - 'How often an ad can appear for Guest users (e.g., a value ' - 'of 5 means an ad could be placed after every 5 news items).', - value: adConfig.guestAdFrequency, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - adConfig: adConfig.copyWith(guestAdFrequency: value), - ), - ); - }, - controller: _guestAdFrequencyController, - ), - widget.buildIntField( - context, - label: 'Guest Ad Placement Interval', - description: - 'Minimum number of news items that must be shown before the ' - 'very first ad appears for Guest users.', - value: adConfig.guestAdPlacementInterval, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - adConfig: adConfig.copyWith( - guestAdPlacementInterval: value, - ), - ), - ); - }, - controller: _guestAdPlacementIntervalController, - ), - widget.buildIntField( - context, - label: 'Guest Articles Before Interstitial Ads', - description: - 'Number of articles a Guest user needs to read before a ' - 'full-screen interstitial ad is shown.', - value: adConfig.guestArticlesToReadBeforeShowingInterstitialAds, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - adConfig: adConfig.copyWith( - guestArticlesToReadBeforeShowingInterstitialAds: value, - ), - ), - ); - }, - controller: - _guestArticlesToReadBeforeShowingInterstitialAdsController, - ), - ], - ); - case UserRoles.standardUser: - return Column( - children: [ - widget.buildIntField( - context, - label: 'Standard User Ad Frequency', - description: - 'How often an ad can appear for Standard users (e.g., a value ' - 'of 10 means an ad could be placed after every 10 news items).', - value: adConfig.authenticatedAdFrequency, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - adConfig: adConfig.copyWith( - authenticatedAdFrequency: value, - ), - ), - ); - }, - controller: _authenticatedAdFrequencyController, - ), - widget.buildIntField( - context, - label: 'Standard User Ad Placement Interval', - description: - 'Minimum number of news items that must be shown before the ' - 'very first ad appears for Standard users.', - value: adConfig.authenticatedAdPlacementInterval, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - adConfig: adConfig.copyWith( - authenticatedAdPlacementInterval: value, - ), - ), - ); - }, - controller: _authenticatedAdPlacementIntervalController, - ), - widget.buildIntField( - context, - label: 'Standard User Articles Before Interstitial Ads', - description: - 'Number of articles a Standard user needs to read before a ' - 'full-screen interstitial ad is shown.', - value: adConfig - .standardUserArticlesToReadBeforeShowingInterstitialAds, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - adConfig: adConfig.copyWith( - standardUserArticlesToReadBeforeShowingInterstitialAds: - value, - ), - ), - ); - }, - controller: - _standardUserArticlesToReadBeforeShowingInterstitialAdsController, - ), - ], - ); - case UserRoles.premiumUser: - return Column( - children: [ - widget.buildIntField( - context, - label: 'Premium Ad Frequency', - description: - 'How often an ad can appear for Premium users (0 for no ads).', - value: adConfig.premiumAdFrequency, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - adConfig: adConfig.copyWith(premiumAdFrequency: value), - ), - ); - }, - controller: _premiumAdFrequencyController, - ), - widget.buildIntField( - context, - label: 'Premium Ad Placement Interval', - description: - 'Minimum number of news items that must be shown before the ' - 'very first ad appears for Premium users.', - value: adConfig.premiumAdPlacementInterval, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - adConfig: adConfig.copyWith( - premiumAdPlacementInterval: value, - ), - ), - ); - }, - controller: _premiumAdPlacementIntervalController, - ), - widget.buildIntField( - context, - label: 'Premium User Articles Before Interstitial Ads', - description: - 'Number of articles a Premium user needs to read before a ' - 'full-screen interstitial ad is shown.', - value: adConfig - .premiumUserArticlesToReadBeforeShowingInterstitialAds, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - adConfig: adConfig.copyWith( - premiumUserArticlesToReadBeforeShowingInterstitialAds: - value, - ), - ), - ); - }, - controller: - _premiumUserArticlesToReadBeforeShowingInterstitialAdsController, - ), - ], - ); - case UserRoles.admin: - return const SizedBox.shrink(); - default: - return const SizedBox.shrink(); + case AppUserRole.guestUser: + return config.guestAdFrequency; + case AppUserRole.standardUser: + return config.authenticatedAdFrequency; + case AppUserRole.premiumUser: + return config.premiumAdFrequency; + } + } + + int _getAdPlacementInterval(AdConfig config) { + switch (widget.userRole) { + case AppUserRole.guestUser: + return config.guestAdPlacementInterval; + case AppUserRole.standardUser: + return config.authenticatedAdPlacementInterval; + case AppUserRole.premiumUser: + return config.premiumAdPlacementInterval; + } + } + + int _getArticlesBeforeInterstitial(AdConfig config) { + switch (widget.userRole) { + case AppUserRole.guestUser: + return config.guestArticlesToReadBeforeShowingInterstitialAds; + case AppUserRole.standardUser: + return config.standardUserArticlesToReadBeforeShowingInterstitialAds; + case AppUserRole.premiumUser: + return config.premiumUserArticlesToReadBeforeShowingInterstitialAds; + } + } + + AdConfig _updateAdFrequency(AdConfig config, int value) { + switch (widget.userRole) { + case AppUserRole.guestUser: + return config.copyWith(guestAdFrequency: value); + case AppUserRole.standardUser: + return config.copyWith(authenticatedAdFrequency: value); + case AppUserRole.premiumUser: + return config.copyWith(premiumAdFrequency: value); + } + } + + AdConfig _updateAdPlacementInterval(AdConfig config, int value) { + switch (widget.userRole) { + case AppUserRole.guestUser: + return config.copyWith(guestAdPlacementInterval: value); + case AppUserRole.standardUser: + return config.copyWith(authenticatedAdPlacementInterval: value); + case AppUserRole.premiumUser: + return config.copyWith(premiumAdPlacementInterval: value); + } + } + + AdConfig _updateArticlesBeforeInterstitial(AdConfig config, int value) { + switch (widget.userRole) { + case AppUserRole.guestUser: + return config.copyWith( + guestArticlesToReadBeforeShowingInterstitialAds: value); + case AppUserRole.standardUser: + return config.copyWith( + standardUserArticlesToReadBeforeShowingInterstitialAds: value); + case AppUserRole.premiumUser: + return config.copyWith( + premiumUserArticlesToReadBeforeShowingInterstitialAds: value); } } } @@ -1463,14 +1086,14 @@ class _AdConfigFormState extends State<_AdConfigForm> { class _AccountActionConfigForm extends StatefulWidget { const _AccountActionConfigForm({ required this.userRole, - required this.appConfig, + required this.remoteConfig, required this.onConfigChanged, required this.buildIntField, }); - final String userRole; - final AppConfig appConfig; - final ValueChanged onConfigChanged; + final AppUserRole userRole; + final RemoteConfig remoteConfig; + final ValueChanged onConfigChanged; final Widget Function( BuildContext context, { required String label, @@ -1478,8 +1101,7 @@ class _AccountActionConfigForm extends StatefulWidget { required int value, required ValueChanged onChanged, TextEditingController? controller, - }) - buildIntField; + }) buildIntField; @override State<_AccountActionConfigForm> createState() => @@ -1487,127 +1109,98 @@ class _AccountActionConfigForm extends StatefulWidget { } class _AccountActionConfigFormState extends State<_AccountActionConfigForm> { - late final TextEditingController _guestDaysBetweenAccountActionsController; - late final TextEditingController - _standardUserDaysBetweenAccountActionsController; + late final Map _controllers; @override void initState() { super.initState(); - _guestDaysBetweenAccountActionsController = TextEditingController( - text: widget.appConfig.accountActionConfig.guestDaysBetweenAccountActions - .toString(), - ); - _standardUserDaysBetweenAccountActionsController = TextEditingController( - text: widget - .appConfig - .accountActionConfig - .standardUserDaysBetweenAccountActions - .toString(), - ); + _controllers = _initializeControllers(); } @override void didUpdateWidget(covariant _AccountActionConfigForm oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.appConfig.accountActionConfig != - oldWidget.appConfig.accountActionConfig) { - _guestDaysBetweenAccountActionsController.value = TextEditingValue( - text: widget - .appConfig - .accountActionConfig - .guestDaysBetweenAccountActions - .toString(), - selection: TextSelection.collapsed( - offset: widget - .appConfig - .accountActionConfig - .guestDaysBetweenAccountActions - .toString() - .length, - ), - ); - _standardUserDaysBetweenAccountActionsController.value = TextEditingValue( - text: widget - .appConfig - .accountActionConfig - .standardUserDaysBetweenAccountActions - .toString(), - selection: TextSelection.collapsed( - offset: widget - .appConfig - .accountActionConfig - .standardUserDaysBetweenAccountActions - .toString() - .length, - ), - ); + if (widget.remoteConfig.accountActionConfig != + oldWidget.remoteConfig.accountActionConfig) { + _updateControllers(); + } + } + + Map _initializeControllers() { + final config = widget.remoteConfig.accountActionConfig; + final daysMap = _getDaysMap(config); + return { + for (final type in FeedActionType.values) + type: TextEditingController(text: (daysMap[type] ?? 0).toString()), + }; + } + + void _updateControllers() { + final config = widget.remoteConfig.accountActionConfig; + final daysMap = _getDaysMap(config); + for (final type in FeedActionType.values) { + _controllers[type]?.text = (daysMap[type] ?? 0).toString(); + } + } + + Map _getDaysMap(AccountActionConfig config) { + switch (widget.userRole) { + case AppUserRole.guestUser: + return config.guestDaysBetweenActions; + case AppUserRole.standardUser: + return config.standardUserDaysBetweenActions; + case AppUserRole.premiumUser: + return {}; } } @override void dispose() { - _guestDaysBetweenAccountActionsController.dispose(); - _standardUserDaysBetweenAccountActionsController.dispose(); + for (final controller in _controllers.values) { + controller.dispose(); + } super.dispose(); } + String _formatLabel(String enumName) { + // Converts camelCase to Title Case + final spaced = enumName.replaceAllMapped( + RegExp(r'([A-Z])'), (match) => ' ${match.group(1)}'); + return '${spaced[0].toUpperCase()}${spaced.substring(1)} Days'; + } + @override Widget build(BuildContext context) { - final accountActionConfig = widget.appConfig.accountActionConfig; + final accountActionConfig = widget.remoteConfig.accountActionConfig; + final relevantActionTypes = + _getDaysMap(accountActionConfig).keys.toList(); - switch (widget.userRole) { - case UserRoles.guestUser: - return Column( - children: [ - widget.buildIntField( - context, - label: 'Guest Days Between In-App Prompts', - description: - 'Minimum number of days that must pass before a Guest user ' - 'sees another in-app prompt.', - value: accountActionConfig.guestDaysBetweenAccountActions, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - accountActionConfig: accountActionConfig.copyWith( - guestDaysBetweenAccountActions: value, - ), - ), - ); - }, - controller: _guestDaysBetweenAccountActionsController, - ), - ], - ); - case UserRoles.standardUser: - return Column( - children: [ - widget.buildIntField( - context, - label: 'Standard User Days Between In-App Prompts', - description: - 'Minimum number of days that must pass before a Standard user ' - 'sees another in-app prompt.', - value: accountActionConfig.standardUserDaysBetweenAccountActions, - onChanged: (value) { - widget.onConfigChanged( - widget.appConfig.copyWith( - accountActionConfig: accountActionConfig.copyWith( - standardUserDaysBetweenAccountActions: value, - ), - ), - ); - }, - controller: _standardUserDaysBetweenAccountActionsController, - ), - ], + return Column( + children: relevantActionTypes.map((actionType) { + return widget.buildIntField( + context, + label: _formatLabel(actionType.name), + description: + 'Minimum number of days before showing the ${actionType.name} prompt.', + value: _getDaysMap(accountActionConfig)[actionType] ?? 0, + onChanged: (value) { + final currentMap = _getDaysMap(accountActionConfig); + final updatedMap = Map.from(currentMap) + ..[actionType] = value; + + final newConfig = widget.userRole == AppUserRole.guestUser + ? accountActionConfig.copyWith( + guestDaysBetweenActions: updatedMap) + : accountActionConfig.copyWith( + standardUserDaysBetweenActions: updatedMap); + + widget.onConfigChanged( + widget.remoteConfig.copyWith(accountActionConfig: newConfig), + ); + }, + controller: _controllers[actionType], ); - case UserRoles.premiumUser: - case UserRoles.admin: - return const SizedBox.shrink(); - default: - return const SizedBox.shrink(); - } + }).toList(), + ); } } diff --git a/lib/authentication/bloc/authentication_bloc.dart b/lib/authentication/bloc/authentication_bloc.dart index 9e3d551..586f196 100644 --- a/lib/authentication/bloc/authentication_bloc.dart +++ b/lib/authentication/bloc/authentication_bloc.dart @@ -3,18 +3,17 @@ 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_shared/ht_shared.dart' - show - AuthenticationException, - ForbiddenException, - HtHttpException, - InvalidInputException, - NetworkException, - NotFoundException, - OperationFailedException, - ServerException, - UnauthorizedException, - User; +import 'package:ht_shared/ht_shared.dart' show + AuthenticationException, + ForbiddenException, + HtHttpException, + InvalidInputException, + NetworkException, + NotFoundException, + OperationFailedException, + ServerException, + UnauthorizedException, + User; part 'authentication_event.dart'; part 'authentication_state.dart'; @@ -27,13 +26,13 @@ class AuthenticationBloc /// {@macro authentication_bloc} AuthenticationBloc({required HtAuthRepository authenticationRepository}) : _authenticationRepository = authenticationRepository, - super(AuthenticationInitial()) { + super(const AuthenticationState()) { // Listen to authentication state changes from the repository _userAuthSubscription = _authenticationRepository.authStateChanges.listen( - (user) => add(_AuthenticationUserChanged(user: user)), + (user) => add(_AuthenticationStatusChanged(user: user)), ); - on<_AuthenticationUserChanged>(_onAuthenticationUserChanged); + on<_AuthenticationStatusChanged>(_onAuthenticationStatusChanged); on( _onAuthenticationRequestSignInCodeRequested, ); @@ -44,15 +43,25 @@ class AuthenticationBloc final HtAuthRepository _authenticationRepository; late final StreamSubscription _userAuthSubscription; - /// Handles [_AuthenticationUserChanged] events. - Future _onAuthenticationUserChanged( - _AuthenticationUserChanged event, + /// Handles [_AuthenticationStatusChanged] events. + Future _onAuthenticationStatusChanged( + _AuthenticationStatusChanged event, Emitter emit, ) async { if (event.user != null) { - emit(AuthenticationAuthenticated(user: event.user!)); + emit( + state.copyWith( + status: AuthenticationStatus.authenticated, + user: event.user, + ), + ); } else { - emit(AuthenticationUnauthenticated()); + emit( + state.copyWith( + status: AuthenticationStatus.unauthenticated, + user: null, + ), + ); } } @@ -63,37 +72,87 @@ class AuthenticationBloc ) async { // Validate email format (basic check) if (event.email.isEmpty || !event.email.contains('@')) { - emit(const AuthenticationFailure('Please enter a valid email address.')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'Please enter a valid email address.', + ), + ); return; } - emit(AuthenticationRequestCodeLoading()); + emit(state.copyWith(status: AuthenticationStatus.requestCodeLoading)); try { await _authenticationRepository.requestSignInCode( event.email, isDashboardLogin: true, ); - emit(AuthenticationCodeSentSuccess(email: event.email)); + emit( + state.copyWith( + status: AuthenticationStatus.codeSentSuccess, + email: event.email, + ), + ); } on InvalidInputException catch (e) { - emit(AuthenticationFailure('Invalid input: ${e.message}')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'Invalid input: ${e.message}', + ), + ); } on UnauthorizedException catch (e) { - emit(AuthenticationFailure(e.message)); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: e.message, + ), + ); } on ForbiddenException catch (e) { - emit(AuthenticationFailure(e.message)); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: e.message, + ), + ); } on NetworkException catch (_) { - emit(const AuthenticationFailure('Network error occurred.')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'Network error occurred.', + ), + ); } on ServerException catch (e) { - emit(AuthenticationFailure('Server error: ${e.message}')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'Server error: ${e.message}', + ), + ); } on OperationFailedException catch (e) { - emit(AuthenticationFailure('Operation failed: ${e.message}')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'Operation failed: ${e.message}', + ), + ); } on HtHttpException catch (e) { // Catch any other HtHttpException subtypes final message = e.message.isNotEmpty ? e.message : 'An unspecified HTTP error occurred.'; - emit(AuthenticationFailure('HTTP error: $message')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'HTTP error: $message', + ), + ); } catch (e) { // Catch any other unexpected errors - emit(AuthenticationFailure('An unexpected error occurred: $e')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'An unexpected error occurred: $e', + ), + ); // Optionally log the stackTrace here } } @@ -103,33 +162,73 @@ class AuthenticationBloc AuthenticationVerifyCodeRequested event, Emitter emit, ) async { - emit(AuthenticationLoading()); + emit(state.copyWith(status: AuthenticationStatus.loading)); try { await _authenticationRepository.verifySignInCode( event.email, event.code, isDashboardLogin: true, ); - // On success, the _AuthenticationUserChanged listener will handle + // On success, the _AuthenticationStatusChanged listener will handle // emitting AuthenticationAuthenticated. } on InvalidInputException catch (e) { - emit(AuthenticationFailure(e.message)); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: e.message, + ), + ); } on AuthenticationException catch (e) { - emit(AuthenticationFailure(e.message)); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: e.message, + ), + ); } on NotFoundException catch (e) { - emit(AuthenticationFailure(e.message)); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: e.message, + ), + ); } on NetworkException catch (_) { - emit(const AuthenticationFailure('Network error occurred.')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'Network error occurred.', + ), + ); } on ServerException catch (e) { - emit(AuthenticationFailure('Server error: ${e.message}')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'Server error: ${e.message}', + ), + ); } on OperationFailedException catch (e) { - emit(AuthenticationFailure('Operation failed: ${e.message}')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'Operation failed: ${e.message}', + ), + ); } on HtHttpException catch (e) { // Catch any other HtHttpException subtypes - emit(AuthenticationFailure('HTTP error: ${e.message}')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'HTTP error: ${e.message}', + ), + ); } catch (e) { // Catch any other unexpected errors - emit(AuthenticationFailure('An unexpected error occurred: $e')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'An unexpected error occurred: $e', + ), + ); // Optionally log the stackTrace here } } @@ -139,26 +238,47 @@ class AuthenticationBloc AuthenticationSignOutRequested event, Emitter emit, ) async { - emit(AuthenticationLoading()); + emit(state.copyWith(status: AuthenticationStatus.loading)); try { await _authenticationRepository.signOut(); - // On success, the _AuthenticationUserChanged listener will handle + // On success, the _AuthenticationStatusChanged listener will handle // emitting AuthenticationUnauthenticated. - // No need to emit AuthenticationLoading() before calling signOut if - // the authStateChanges listener handles the subsequent state update. - // However, if immediate feedback is desired, it can be kept. - // For now, let's assume the listener is sufficient. } on NetworkException catch (_) { - emit(const AuthenticationFailure('Network error occurred.')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'Network error occurred.', + ), + ); } on ServerException catch (e) { - emit(AuthenticationFailure('Server error: ${e.message}')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'Server error: ${e.message}', + ), + ); } on OperationFailedException catch (e) { - emit(AuthenticationFailure('Operation failed: ${e.message}')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'Operation failed: ${e.message}', + ), + ); } on HtHttpException catch (e) { // Catch any other HtHttpException subtypes - emit(AuthenticationFailure('HTTP error: ${e.message}')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'HTTP error: ${e.message}', + ), + ); } catch (e) { - emit(AuthenticationFailure('An unexpected error occurred: $e')); + emit( + state.copyWith( + status: AuthenticationStatus.failure, + errorMessage: 'An unexpected error occurred: $e', + ), + ); } } diff --git a/lib/authentication/bloc/authentication_event.dart b/lib/authentication/bloc/authentication_event.dart index 6034e48..c600111 100644 --- a/lib/authentication/bloc/authentication_event.dart +++ b/lib/authentication/bloc/authentication_event.dart @@ -55,12 +55,12 @@ final class AuthenticationSignOutRequested extends AuthenticationEvent { const AuthenticationSignOutRequested(); } -/// {@template _authentication_user_changed} -/// Internal event triggered when the authentication state changes. +/// {@template _authentication_status_changed} +/// Internal event triggered when the authentication status changes. /// {@endtemplate} -final class _AuthenticationUserChanged extends AuthenticationEvent { - /// {@macro _authentication_user_changed} - const _AuthenticationUserChanged({required this.user}); +final class _AuthenticationStatusChanged extends AuthenticationEvent { + /// {@macro _authentication_status_changed} + const _AuthenticationStatusChanged({this.user}); /// The current authenticated user, or null if unauthenticated. final User? user; diff --git a/lib/authentication/bloc/authentication_state.dart b/lib/authentication/bloc/authentication_state.dart index 67c1650..f4ba12b 100644 --- a/lib/authentication/bloc/authentication_state.dart +++ b/lib/authentication/bloc/authentication_state.dart @@ -1,74 +1,71 @@ part of 'authentication_bloc.dart'; -/// {@template authentication_state} -/// Base class for authentication states. +/// {@template authentication_status} +/// The status of the authentication process. /// {@endtemplate} -sealed class AuthenticationState extends Equatable { - /// {@macro authentication_state} - const AuthenticationState(); +enum AuthenticationStatus { + /// The initial state of the authentication bloc. + initial, - @override - List get props => []; -} + /// An authentication operation is in progress. + loading, -/// {@template authentication_initial} -/// The initial authentication state. -/// {@endtemplate} -final class AuthenticationInitial extends AuthenticationState {} + /// The user is authenticated. + authenticated, -/// {@template authentication_loading} -/// A state indicating that an authentication operation is in progress. -/// {@endtemplate} -final class AuthenticationLoading extends AuthenticationState {} + /// The user is unauthenticated. + unauthenticated, -/// {@template authentication_authenticated} -/// Represents a successful authentication. -/// {@endtemplate} -final class AuthenticationAuthenticated extends AuthenticationState { - /// {@macro authentication_authenticated} - const AuthenticationAuthenticated({required this.user}); + /// The sign-in code is being requested. + requestCodeLoading, - /// The authenticated [User] object. - final User user; + /// The sign-in code was sent successfully. + codeSentSuccess, - @override - List get props => [user]; + /// An authentication operation failed. + failure, } -/// {@template authentication_unauthenticated} -/// Represents an unauthenticated state. -/// {@endtemplate} -final class AuthenticationUnauthenticated extends AuthenticationState {} - -/// {@template authentication_request_code_loading} -/// State indicating that the sign-in code is being requested. +/// {@template authentication_state} +/// Represents the overall authentication state of the application. /// {@endtemplate} -final class AuthenticationRequestCodeLoading extends AuthenticationState {} +final class AuthenticationState extends Equatable { + /// {@macro authentication_state} + const AuthenticationState({ + this.status = AuthenticationStatus.initial, + this.user, + this.email, + this.errorMessage, + }); -/// {@template authentication_code_sent_success} -/// State indicating that the sign-in code was sent successfully. -/// {@endtemplate} -final class AuthenticationCodeSentSuccess extends AuthenticationState { - /// {@macro authentication_code_sent_success} - const AuthenticationCodeSentSuccess({required this.email}); + /// The current status of the authentication process. + final AuthenticationStatus status; - /// The email address the code was sent to. - final String email; + /// The authenticated [User] object, if available. + final User? user; - @override - List get props => [email]; -} - -/// {@template authentication_failure} -/// Represents an authentication failure. -/// {@endtemplate} -final class AuthenticationFailure extends AuthenticationState { - /// {@macro authentication_failure} - const AuthenticationFailure(this.errorMessage); + /// The email address involved in the current authentication flow. + final String? email; - /// The error message describing the authentication failure. - final String errorMessage; + /// The error message describing an authentication failure, if any. + final String? errorMessage; @override - List get props => [errorMessage]; + List get props => [status, user, email, errorMessage]; + + /// Creates a copy of this [AuthenticationState] with the given fields + /// replaced with the new values. + AuthenticationState copyWith({ + AuthenticationStatus? status, + User? user, + String? email, + String? errorMessage, + }) { + return AuthenticationState( + status: status ?? this.status, + user: user ?? this.user, + email: email ?? this.email, + errorMessage: errorMessage ?? this.errorMessage, + ); + } } diff --git a/lib/authentication/view/authentication_page.dart b/lib/authentication/view/authentication_page.dart index e7e26b7..a3727e1 100644 --- a/lib/authentication/view/authentication_page.dart +++ b/lib/authentication/view/authentication_page.dart @@ -1,6 +1,3 @@ -// -// ignore_for_file: lines_longer_than_80_chars - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -34,25 +31,27 @@ class AuthenticationPage extends StatelessWidget { child: BlocConsumer( // Listener remains crucial for feedback (errors) listener: (context, state) { - if (state is AuthenticationFailure) { + if (state.status == AuthenticationStatus.failure) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( content: Text( // Provide a more user-friendly error message if possible - state.errorMessage, + state.errorMessage!, ), backgroundColor: colorScheme.error, ), ); } // Success states (Google/Anonymous) are typically handled by - // the AppBloc listening to repository changes and triggering redirects. - // Email link success is handled in the dedicated email flow pages. + // the AppBloc listening to repository changes and triggering + // redirects. Email link success is handled in the dedicated + // email flow pages. }, builder: (context, state) { - final isLoading = state is AuthenticationLoading; + final isLoading = state.status == AuthenticationStatus.loading || + state.status == AuthenticationStatus.requestCodeLoading; return Padding( padding: const EdgeInsets.all(AppSpacing.paddingLarge), @@ -64,14 +63,15 @@ class AuthenticationPage extends StatelessWidget { children: [ // --- Icon --- Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.xl), + padding: const EdgeInsets.only( + bottom: AppSpacing.xl, + ), child: Icon( Icons.newspaper, size: AppSpacing.xxl * 2, color: colorScheme.primary, ), ), - // const SizedBox(height: AppSpacing.lg), // --- Headline and Subheadline --- Text( l10n.authenticationPageHeadline, @@ -111,13 +111,11 @@ class AuthenticationPage extends StatelessWidget { const SizedBox(height: AppSpacing.lg), // --- Loading Indicator --- - if (isLoading && - state is! AuthenticationRequestCodeLoading) ...[ + if (isLoading) const Padding( padding: EdgeInsets.only(top: AppSpacing.xl), child: Center(child: CircularProgressIndicator()), ), - ], ], ), ), diff --git a/lib/authentication/view/email_code_verification_page.dart b/lib/authentication/view/email_code_verification_page.dart index cf4f668..1635a59 100644 --- a/lib/authentication/view/email_code_verification_page.dart +++ b/lib/authentication/view/email_code_verification_page.dart @@ -29,12 +29,12 @@ class EmailCodeVerificationPage extends StatelessWidget { body: SafeArea( child: BlocConsumer( listener: (context, state) { - if (state is AuthenticationFailure) { + if (state.status == AuthenticationStatus.failure) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( - content: Text(state.errorMessage), + content: Text(state.errorMessage!), backgroundColor: colorScheme.error, ), ); @@ -42,7 +42,7 @@ class EmailCodeVerificationPage extends StatelessWidget { // Successful authentication is handled by AppBloc redirecting. }, builder: (context, state) { - final isLoading = state is AuthenticationLoading; + final isLoading = state.status == AuthenticationStatus.loading; return Padding( padding: const EdgeInsets.all(AppSpacing.paddingLarge), diff --git a/lib/authentication/view/request_code_page.dart b/lib/authentication/view/request_code_page.dart index e5dde0e..ca9116b 100644 --- a/lib/authentication/view/request_code_page.dart +++ b/lib/authentication/view/request_code_page.dart @@ -70,32 +70,33 @@ class _RequestCodeView extends StatelessWidget { body: SafeArea( child: BlocConsumer( listener: (context, state) { - if (state is AuthenticationFailure) { + if (state.status == AuthenticationStatus.failure) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( - content: Text(state.errorMessage), + content: Text(state.errorMessage!), backgroundColor: colorScheme.error, ), ); - } else if (state is AuthenticationCodeSentSuccess) { + } else if (state.status == AuthenticationStatus.codeSentSuccess) { // Navigate to the code verification page on success, passing the email context.goNamed( isLinkingContext ? Routes.linkingVerifyCodeName : Routes.verifyCodeName, - pathParameters: {'email': state.email}, + pathParameters: {'email': state.email!}, ); } }, // BuildWhen prevents unnecessary rebuilds if only listening buildWhen: (previous, current) => - current is AuthenticationInitial || - current is AuthenticationRequestCodeLoading || - current is AuthenticationFailure, + previous.status != current.status || + previous.errorMessage != current.errorMessage || + previous.email != current.email, builder: (context, state) { - final isLoading = state is AuthenticationRequestCodeLoading; + final isLoading = + state.status == AuthenticationStatus.requestCodeLoading; return Padding( padding: const EdgeInsets.all(AppSpacing.paddingLarge), diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 2c1827e..7174733 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -17,6 +17,7 @@ import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_http_client/ht_http_client.dart'; import 'package:ht_kv_storage_shared_preferences/ht_kv_storage_shared_preferences.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; import 'package:timeago/timeago.dart' as timeago; Future bootstrap( @@ -36,7 +37,9 @@ Future bootstrap( HtHttpClient? httpClient; if (appConfig.environment == app_config.AppEnvironment.demo) { - authClient = HtAuthInmemory(); + authClient = HtAuthInmemory( + logger: Logger('HtAuthInmemory'), + ); authenticationRepository = HtAuthRepository( authClient: authClient, storageService: kvStorage, @@ -47,7 +50,10 @@ Future bootstrap( tokenProvider: () => authenticationRepository.getAuthToken(), isWeb: kIsWeb, ); - authClient = HtAuthApi(httpClient: httpClient); + authClient = HtAuthApi( + httpClient: httpClient, + logger: Logger('HtAuthApi'), + ); authenticationRepository = HtAuthRepository( authClient: authClient, storageService: kvStorage, @@ -55,54 +61,60 @@ Future bootstrap( } HtDataClient headlinesClient; - HtDataClient categoriesClient; + HtDataClient topicsClient; HtDataClient countriesClient; HtDataClient sourcesClient; HtDataClient userContentPreferencesClient; HtDataClient userAppSettingsClient; - HtDataClient appConfigClient; + HtDataClient remoteConfigClient; HtDataClient dashboardSummaryClient; if (appConfig.environment == app_config.AppEnvironment.demo) { headlinesClient = HtDataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, - initialData: headlinesFixturesData.map(Headline.fromJson).toList(), + initialData: headlinesFixturesData, + logger: Logger('HtDataInMemory'), ); - categoriesClient = HtDataInMemory( + topicsClient = HtDataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, - initialData: categoriesFixturesData.map(Category.fromJson).toList(), + initialData: topicsFixturesData, + logger: Logger('HtDataInMemory'), ); countriesClient = HtDataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, - initialData: countriesFixturesData.map(Country.fromJson).toList(), + initialData: countriesFixturesData, + logger: Logger('HtDataInMemory'), ); sourcesClient = HtDataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, - initialData: sourcesFixturesData.map(Source.fromJson).toList(), + initialData: sourcesFixturesData, + logger: Logger('HtDataInMemory'), ); userContentPreferencesClient = HtDataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, + logger: Logger('HtDataInMemory'), ); userAppSettingsClient = HtDataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, + logger: Logger('HtDataInMemory'), ); - appConfigClient = HtDataInMemory( + remoteConfigClient = HtDataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, - initialData: [AppConfig.fromJson(appConfigFixtureData)], + initialData: remoteConfigsFixturesData, + logger: Logger('HtDataInMemory'), ); dashboardSummaryClient = HtDataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, - initialData: [ - DashboardSummary.fromJson(dashboardSummaryFixtureData), - ], + initialData: dashboardSummaryFixturesData, + logger: Logger('HtDataInMemory'), ); } else if (appConfig.environment == app_config.AppEnvironment.development) { headlinesClient = HtDataApi( @@ -110,48 +122,56 @@ Future bootstrap( modelName: 'headline', fromJson: Headline.fromJson, toJson: (headline) => headline.toJson(), + logger: Logger('HtDataApi'), ); - categoriesClient = HtDataApi( + topicsClient = HtDataApi( httpClient: httpClient, - modelName: 'category', - fromJson: Category.fromJson, - toJson: (category) => category.toJson(), + modelName: 'topic', + fromJson: Topic.fromJson, + toJson: (topic) => topic.toJson(), + logger: Logger('HtDataApi'), ); countriesClient = HtDataApi( httpClient: httpClient, modelName: 'country', fromJson: Country.fromJson, toJson: (country) => country.toJson(), + logger: Logger('HtDataApi'), ); sourcesClient = HtDataApi( httpClient: httpClient, modelName: 'source', fromJson: Source.fromJson, toJson: (source) => source.toJson(), + logger: Logger('HtDataApi'), ); userContentPreferencesClient = HtDataApi( httpClient: httpClient, modelName: 'user_content_preferences', fromJson: UserContentPreferences.fromJson, toJson: (prefs) => prefs.toJson(), + logger: Logger('HtDataApi'), ); userAppSettingsClient = HtDataApi( httpClient: httpClient, modelName: 'user_app_settings', fromJson: UserAppSettings.fromJson, toJson: (settings) => settings.toJson(), + logger: Logger('HtDataApi'), ); - appConfigClient = HtDataApi( + remoteConfigClient = HtDataApi( httpClient: httpClient, - modelName: 'app_config', - fromJson: AppConfig.fromJson, + modelName: 'remote_config', + fromJson: RemoteConfig.fromJson, toJson: (config) => config.toJson(), + logger: Logger('HtDataApi'), ); dashboardSummaryClient = HtDataApi( httpClient: httpClient, modelName: 'dashboard_summary', fromJson: DashboardSummary.fromJson, toJson: (summary) => summary.toJson(), + logger: Logger('HtDataApi'), ); } else { headlinesClient = HtDataApi( @@ -159,56 +179,64 @@ Future bootstrap( modelName: 'headline', fromJson: Headline.fromJson, toJson: (headline) => headline.toJson(), + logger: Logger('HtDataApi'), ); - categoriesClient = HtDataApi( + topicsClient = HtDataApi( httpClient: httpClient, - modelName: 'category', - fromJson: Category.fromJson, - toJson: (category) => category.toJson(), + modelName: 'topic', + fromJson: Topic.fromJson, + toJson: (topic) => topic.toJson(), + logger: Logger('HtDataApi'), ); countriesClient = HtDataApi( httpClient: httpClient, modelName: 'country', fromJson: Country.fromJson, toJson: (country) => country.toJson(), + logger: Logger('HtDataApi'), ); sourcesClient = HtDataApi( httpClient: httpClient, modelName: 'source', fromJson: Source.fromJson, toJson: (source) => source.toJson(), + logger: Logger('HtDataApi'), ); userContentPreferencesClient = HtDataApi( httpClient: httpClient, modelName: 'user_content_preferences', fromJson: UserContentPreferences.fromJson, toJson: (prefs) => prefs.toJson(), + logger: Logger('HtDataApi'), ); userAppSettingsClient = HtDataApi( httpClient: httpClient, modelName: 'user_app_settings', fromJson: UserAppSettings.fromJson, toJson: (settings) => settings.toJson(), + logger: Logger('HtDataApi'), ); - appConfigClient = HtDataApi( + remoteConfigClient = HtDataApi( httpClient: httpClient, - modelName: 'app_config', - fromJson: AppConfig.fromJson, + modelName: 'remote_config', + fromJson: RemoteConfig.fromJson, toJson: (config) => config.toJson(), + logger: Logger('HtDataApi'), ); dashboardSummaryClient = HtDataApi( httpClient: httpClient, modelName: 'dashboard_summary', fromJson: DashboardSummary.fromJson, toJson: (summary) => summary.toJson(), + logger: Logger('HtDataApi'), ); } final headlinesRepository = HtDataRepository( dataClient: headlinesClient, ); - final categoriesRepository = HtDataRepository( - dataClient: categoriesClient, + final topicsRepository = HtDataRepository( + dataClient: topicsClient, ); final countriesRepository = HtDataRepository( dataClient: countriesClient, @@ -221,8 +249,8 @@ Future bootstrap( final userAppSettingsRepository = HtDataRepository( dataClient: userAppSettingsClient, ); - final appConfigRepository = HtDataRepository( - dataClient: appConfigClient, + final remoteConfigRepository = HtDataRepository( + dataClient: remoteConfigClient, ); final dashboardSummaryRepository = HtDataRepository( dataClient: dashboardSummaryClient, @@ -231,12 +259,12 @@ Future bootstrap( return App( htAuthenticationRepository: authenticationRepository, htHeadlinesRepository: headlinesRepository, - htCategoriesRepository: categoriesRepository, + htTopicsRepository: topicsRepository, htCountriesRepository: countriesRepository, htSourcesRepository: sourcesRepository, htUserAppSettingsRepository: userAppSettingsRepository, htUserContentPreferencesRepository: userContentPreferencesRepository, - htAppConfigRepository: appConfigRepository, + htRemoteConfigRepository: remoteConfigRepository, htDashboardSummaryRepository: dashboardSummaryRepository, kvStorageService: kvStorage, environment: environment, diff --git a/lib/content_management/bloc/content_management_bloc.dart b/lib/content_management/bloc/content_management_bloc.dart index cad4988..0d0a258 100644 --- a/lib/content_management/bloc/content_management_bloc.dart +++ b/lib/content_management/bloc/content_management_bloc.dart @@ -11,8 +11,8 @@ enum ContentManagementTab { /// Represents the Headlines tab. headlines, - /// Represents the Categories tab. - categories, + /// Represents the Topics tab. + topics, /// Represents the Sources tab. sources, @@ -22,26 +22,26 @@ class ContentManagementBloc extends Bloc { ContentManagementBloc({ required HtDataRepository headlinesRepository, - required HtDataRepository categoriesRepository, + required HtDataRepository topicsRepository, required HtDataRepository sourcesRepository, }) : _headlinesRepository = headlinesRepository, - _categoriesRepository = categoriesRepository, + _topicsRepository = topicsRepository, _sourcesRepository = sourcesRepository, super(const ContentManagementState()) { on(_onContentManagementTabChanged); on(_onLoadHeadlinesRequested); on(_onHeadlineUpdated); on(_onDeleteHeadlineRequested); - on(_onLoadCategoriesRequested); - on(_onCategoryUpdated); - on(_onDeleteCategoryRequested); + on(_onLoadTopicsRequested); + on(_onTopicUpdated); + on(_onDeleteTopicRequested); on(_onLoadSourcesRequested); on(_onSourceUpdated); - on(_onOnDeleteSourceRequested); + on(_onDeleteSourceRequested); } final HtDataRepository _headlinesRepository; - final HtDataRepository _categoriesRepository; + final HtDataRepository _topicsRepository; final HtDataRepository _sourcesRepository; void _onContentManagementTabChanged( @@ -61,8 +61,10 @@ class ContentManagementBloc final previousHeadlines = isPaginating ? state.headlines : []; final paginatedHeadlines = await _headlinesRepository.readAll( - startAfterId: event.startAfterId, - limit: event.limit, + pagination: PaginationOptions( + cursor: event.startAfterId, + limit: event.limit, + ), ); emit( state.copyWith( @@ -128,82 +130,82 @@ class ContentManagementBloc } } - Future _onLoadCategoriesRequested( - LoadCategoriesRequested event, + Future _onLoadTopicsRequested( + LoadTopicsRequested event, Emitter emit, ) async { - emit(state.copyWith(categoriesStatus: ContentManagementStatus.loading)); + emit(state.copyWith(topicsStatus: ContentManagementStatus.loading)); try { final isPaginating = event.startAfterId != null; - final previousCategories = isPaginating ? state.categories : []; + final previousTopics = isPaginating ? state.topics : []; - final paginatedCategories = await _categoriesRepository.readAll( - startAfterId: event.startAfterId, - limit: event.limit, + final paginatedTopics = await _topicsRepository.readAll( + pagination: PaginationOptions( + cursor: event.startAfterId, + limit: event.limit, + ), ); emit( state.copyWith( - categoriesStatus: ContentManagementStatus.success, - categories: [...previousCategories, ...paginatedCategories.items], - categoriesCursor: paginatedCategories.cursor, - categoriesHasMore: paginatedCategories.hasMore, + topicsStatus: ContentManagementStatus.success, + topics: [...previousTopics, ...paginatedTopics.items], + topicsCursor: paginatedTopics.cursor, + topicsHasMore: paginatedTopics.hasMore, ), ); } on HtHttpException catch (e) { emit( state.copyWith( - categoriesStatus: ContentManagementStatus.failure, + topicsStatus: ContentManagementStatus.failure, errorMessage: e.message, ), ); } catch (e) { emit( state.copyWith( - categoriesStatus: ContentManagementStatus.failure, + topicsStatus: ContentManagementStatus.failure, errorMessage: e.toString(), ), ); } } - Future _onDeleteCategoryRequested( - DeleteCategoryRequested event, + Future _onDeleteTopicRequested( + DeleteTopicRequested event, Emitter emit, ) async { try { - await _categoriesRepository.delete(id: event.id); - final updatedCategories = state.categories + await _topicsRepository.delete(id: event.id); + final updatedTopics = state.topics .where((c) => c.id != event.id) .toList(); - emit(state.copyWith(categories: updatedCategories)); + emit(state.copyWith(topics: updatedTopics)); } on HtHttpException catch (e) { emit( state.copyWith( - categoriesStatus: ContentManagementStatus.failure, + topicsStatus: ContentManagementStatus.failure, errorMessage: e.message, ), ); } catch (e) { emit( state.copyWith( - categoriesStatus: ContentManagementStatus.failure, + topicsStatus: ContentManagementStatus.failure, errorMessage: e.toString(), ), ); } } - void _onCategoryUpdated( - CategoryUpdated event, + void _onTopicUpdated( + TopicUpdated event, Emitter emit, ) { - final updatedCategories = List.from(state.categories); - final index = updatedCategories.indexWhere( - (c) => c.id == event.category.id, - ); + final updatedTopics = List.from(state.topics); + final index = updatedTopics.indexWhere((t) => t.id == event.topic.id); if (index != -1) { - updatedCategories[index] = event.category; - emit(state.copyWith(categories: updatedCategories)); + updatedTopics[index] = event.topic; + emit(state.copyWith(topics: updatedTopics)); } } @@ -217,8 +219,10 @@ class ContentManagementBloc final previousSources = isPaginating ? state.sources : []; final paginatedSources = await _sourcesRepository.readAll( - startAfterId: event.startAfterId, - limit: event.limit, + pagination: PaginationOptions( + cursor: event.startAfterId, + limit: event.limit, + ), ); emit( state.copyWith( @@ -245,7 +249,7 @@ class ContentManagementBloc } } - Future _onOnDeleteSourceRequested( + Future _onDeleteSourceRequested( DeleteSourceRequested event, Emitter emit, ) async { diff --git a/lib/content_management/bloc/content_management_event.dart b/lib/content_management/bloc/content_management_event.dart index be4a003..e9fb3ca 100644 --- a/lib/content_management/bloc/content_management_event.dart +++ b/lib/content_management/bloc/content_management_event.dart @@ -66,12 +66,12 @@ final class HeadlineUpdated extends ContentManagementEvent { List get props => [headline]; } -/// {@template load_categories_requested} -/// Event to request loading of categories. +/// {@template load_topics_requested} +/// Event to request loading of topics. /// {@endtemplate} -final class LoadCategoriesRequested extends ContentManagementEvent { - /// {@macro load_categories_requested} - const LoadCategoriesRequested({this.startAfterId, this.limit}); +final class LoadTopicsRequested extends ContentManagementEvent { + /// {@macro load_topics_requested} + const LoadTopicsRequested({this.startAfterId, this.limit}); /// Optional ID to start pagination after. final String? startAfterId; @@ -83,32 +83,32 @@ final class LoadCategoriesRequested extends ContentManagementEvent { List get props => [startAfterId, limit]; } -/// {@template delete_category_requested} -/// Event to request deletion of a category. +/// {@template delete_topic_requested} +/// Event to request deletion of a topic. /// {@endtemplate} -final class DeleteCategoryRequested extends ContentManagementEvent { - /// {@macro delete_category_requested} - const DeleteCategoryRequested(this.id); +final class DeleteTopicRequested extends ContentManagementEvent { + /// {@macro delete_topic_requested} + const DeleteTopicRequested(this.id); - /// The ID of the category to delete. + /// The ID of the topic to delete. final String id; @override List get props => [id]; } -/// {@template category_updated} -/// Event to update an existing category in the local state. +/// {@template topic_updated} +/// Event to update an existing topic in the local state. /// {@endtemplate} -final class CategoryUpdated extends ContentManagementEvent { - /// {@macro category_updated} - const CategoryUpdated(this.category); +final class TopicUpdated extends ContentManagementEvent { + /// {@macro topic_updated} + const TopicUpdated(this.topic); - /// The category that was updated. - final Category category; + /// The topic that was updated. + final Topic topic; @override - List get props => [category]; + List get props => [topic]; } /// {@template load_sources_requested} diff --git a/lib/content_management/bloc/content_management_state.dart b/lib/content_management/bloc/content_management_state.dart index d566cc6..8728613 100644 --- a/lib/content_management/bloc/content_management_state.dart +++ b/lib/content_management/bloc/content_management_state.dart @@ -24,10 +24,10 @@ class ContentManagementState extends Equatable { this.headlines = const [], this.headlinesCursor, this.headlinesHasMore = false, - this.categoriesStatus = ContentManagementStatus.initial, - this.categories = const [], - this.categoriesCursor, - this.categoriesHasMore = false, + this.topicsStatus = ContentManagementStatus.initial, + this.topics = const [], + this.topicsCursor, + this.topicsHasMore = false, this.sourcesStatus = ContentManagementStatus.initial, this.sources = const [], this.sourcesCursor, @@ -50,17 +50,17 @@ class ContentManagementState extends Equatable { /// Indicates if there are more headlines to load. final bool headlinesHasMore; - /// Status of category data operations. - final ContentManagementStatus categoriesStatus; + /// Status of topic data operations. + final ContentManagementStatus topicsStatus; - /// List of categories. - final List categories; + /// List of topics. + final List topics; - /// Cursor for category pagination. - final String? categoriesCursor; + /// Cursor for topic pagination. + final String? topicsCursor; - /// Indicates if there are more categories to load. - final bool categoriesHasMore; + /// Indicates if there are more topics to load. + final bool topicsHasMore; /// Status of source data operations. final ContentManagementStatus sourcesStatus; @@ -84,10 +84,10 @@ class ContentManagementState extends Equatable { List? headlines, String? headlinesCursor, bool? headlinesHasMore, - ContentManagementStatus? categoriesStatus, - List? categories, - String? categoriesCursor, - bool? categoriesHasMore, + ContentManagementStatus? topicsStatus, + List? topics, + String? topicsCursor, + bool? topicsHasMore, ContentManagementStatus? sourcesStatus, List? sources, String? sourcesCursor, @@ -100,10 +100,10 @@ class ContentManagementState extends Equatable { headlines: headlines ?? this.headlines, headlinesCursor: headlinesCursor ?? this.headlinesCursor, headlinesHasMore: headlinesHasMore ?? this.headlinesHasMore, - categoriesStatus: categoriesStatus ?? this.categoriesStatus, - categories: categories ?? this.categories, - categoriesCursor: categoriesCursor ?? this.categoriesCursor, - categoriesHasMore: categoriesHasMore ?? this.categoriesHasMore, + topicsStatus: topicsStatus ?? this.topicsStatus, + topics: topics ?? this.topics, + topicsCursor: topicsCursor ?? this.topicsCursor, + topicsHasMore: topicsHasMore ?? this.topicsHasMore, sourcesStatus: sourcesStatus ?? this.sourcesStatus, sources: sources ?? this.sources, sourcesCursor: sourcesCursor ?? this.sourcesCursor, @@ -119,10 +119,10 @@ class ContentManagementState extends Equatable { headlines, headlinesCursor, headlinesHasMore, - categoriesStatus, - categories, - categoriesCursor, - categoriesHasMore, + topicsStatus, + topics, + topicsCursor, + topicsHasMore, sourcesStatus, sources, sourcesCursor, diff --git a/lib/content_management/bloc/create_category/create_category_bloc.dart b/lib/content_management/bloc/create_category/create_category_bloc.dart deleted file mode 100644 index 773d6e3..0000000 --- a/lib/content_management/bloc/create_category/create_category_bloc.dart +++ /dev/null @@ -1,115 +0,0 @@ -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'; - -part 'create_category_event.dart'; -part 'create_category_state.dart'; - -/// A BLoC to manage the state of creating a new category. -class CreateCategoryBloc - extends Bloc { - /// {@macro create_category_bloc} - CreateCategoryBloc({ - required HtDataRepository categoriesRepository, - }) : _categoriesRepository = categoriesRepository, - super(const CreateCategoryState()) { - on(_onNameChanged); - on(_onDescriptionChanged); - on(_onIconUrlChanged); - on(_onStatusChanged); - on(_onSubmitted); - } - - final HtDataRepository _categoriesRepository; - - void _onNameChanged( - CreateCategoryNameChanged event, - Emitter emit, - ) { - emit( - state.copyWith( - name: event.name, - status: CreateCategoryStatus.initial, - ), - ); - } - - void _onDescriptionChanged( - CreateCategoryDescriptionChanged event, - Emitter emit, - ) { - emit( - state.copyWith( - description: event.description, - status: CreateCategoryStatus.initial, - ), - ); - } - - void _onIconUrlChanged( - CreateCategoryIconUrlChanged event, - Emitter emit, - ) { - emit( - state.copyWith( - iconUrl: event.iconUrl, - status: CreateCategoryStatus.initial, - ), - ); - } - - void _onStatusChanged( - CreateCategoryStatusChanged event, - Emitter emit, - ) { - emit( - state.copyWith( - contentStatus: event.status, - status: CreateCategoryStatus.initial, - ), - ); - } - - Future _onSubmitted( - CreateCategorySubmitted event, - Emitter emit, - ) async { - if (!state.isFormValid) return; - - emit(state.copyWith(status: CreateCategoryStatus.submitting)); - try { - final now = DateTime.now(); - final newCategory = Category( - name: state.name, - description: state.description.isNotEmpty ? state.description : null, - iconUrl: state.iconUrl.isNotEmpty ? state.iconUrl : null, - status: state.contentStatus, - createdAt: now, - updatedAt: now, - ); - - await _categoriesRepository.create(item: newCategory); - emit( - state.copyWith( - status: CreateCategoryStatus.success, - createdCategory: newCategory, - ), - ); - } on HtHttpException catch (e) { - emit( - state.copyWith( - status: CreateCategoryStatus.failure, - errorMessage: e.message, - ), - ); - } catch (e) { - emit( - state.copyWith( - status: CreateCategoryStatus.failure, - errorMessage: e.toString(), - ), - ); - } - } -} diff --git a/lib/content_management/bloc/create_category/create_category_event.dart b/lib/content_management/bloc/create_category/create_category_event.dart deleted file mode 100644 index 591e570..0000000 --- a/lib/content_management/bloc/create_category/create_category_event.dart +++ /dev/null @@ -1,47 +0,0 @@ -part of 'create_category_bloc.dart'; - -/// Base class for all events related to the [CreateCategoryBloc]. -sealed class CreateCategoryEvent extends Equatable { - const CreateCategoryEvent(); - - @override - List get props => []; -} - -/// Event for when the category's name is changed. -final class CreateCategoryNameChanged extends CreateCategoryEvent { - const CreateCategoryNameChanged(this.name); - final String name; - @override - List get props => [name]; -} - -/// Event for when the category's description is changed. -final class CreateCategoryDescriptionChanged extends CreateCategoryEvent { - const CreateCategoryDescriptionChanged(this.description); - final String description; - @override - List get props => [description]; -} - -/// Event for when the category's icon URL is changed. -final class CreateCategoryIconUrlChanged extends CreateCategoryEvent { - const CreateCategoryIconUrlChanged(this.iconUrl); - final String iconUrl; - @override - List get props => [iconUrl]; -} - -/// Event for when the category's status is changed. -final class CreateCategoryStatusChanged extends CreateCategoryEvent { - const CreateCategoryStatusChanged(this.status); - - final ContentStatus status; - @override - List get props => [status]; -} - -/// Event to signal that the form should be submitted. -final class CreateCategorySubmitted extends CreateCategoryEvent { - const CreateCategorySubmitted(); -} diff --git a/lib/content_management/bloc/create_headline/create_headline_bloc.dart b/lib/content_management/bloc/create_headline/create_headline_bloc.dart index 45f029d..5905b6c 100644 --- a/lib/content_management/bloc/create_headline/create_headline_bloc.dart +++ b/lib/content_management/bloc/create_headline/create_headline_bloc.dart @@ -1,8 +1,9 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart' hide Category; +import 'package:flutter/foundation.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:uuid/uuid.dart'; part 'create_headline_event.dart'; part 'create_headline_state.dart'; @@ -14,25 +15,30 @@ class CreateHeadlineBloc CreateHeadlineBloc({ required HtDataRepository headlinesRepository, required HtDataRepository sourcesRepository, - required HtDataRepository categoriesRepository, - }) : _headlinesRepository = headlinesRepository, - _sourcesRepository = sourcesRepository, - _categoriesRepository = categoriesRepository, - super(const CreateHeadlineState()) { + required HtDataRepository topicsRepository, + required HtDataRepository countriesRepository, + }) : _headlinesRepository = headlinesRepository, + _sourcesRepository = sourcesRepository, + _topicsRepository = topicsRepository, + _countriesRepository = countriesRepository, + super(const CreateHeadlineState()) { on(_onDataLoaded); on(_onTitleChanged); - on(_onDescriptionChanged); + on(_onExcerptChanged); on(_onUrlChanged); on(_onImageUrlChanged); on(_onSourceChanged); - on(_onCategoryChanged); + on(_onTopicChanged); + on(_onCountryChanged); on(_onStatusChanged); on(_onSubmitted); } final HtDataRepository _headlinesRepository; final HtDataRepository _sourcesRepository; - final HtDataRepository _categoriesRepository; + final HtDataRepository _topicsRepository; + final HtDataRepository _countriesRepository; + final _uuid = const Uuid(); Future _onDataLoaded( CreateHeadlineDataLoaded event, @@ -40,20 +46,24 @@ class CreateHeadlineBloc ) async { emit(state.copyWith(status: CreateHeadlineStatus.loading)); try { - final [sourcesResponse, categoriesResponse] = await Future.wait([ + final [sourcesResponse, topicsResponse, countriesResponse] = + await Future.wait([ _sourcesRepository.readAll(), - _categoriesRepository.readAll(), + _topicsRepository.readAll(), + _countriesRepository.readAll(), ]); final sources = (sourcesResponse as PaginatedResponse).items; - final categories = - (categoriesResponse as PaginatedResponse).items; + final topics = (topicsResponse as PaginatedResponse).items; + final countries = + (countriesResponse as PaginatedResponse).items; emit( state.copyWith( status: CreateHeadlineStatus.initial, sources: sources, - categories: categories, + topics: topics, + countries: countries, ), ); } on HtHttpException catch (e) { @@ -80,11 +90,11 @@ class CreateHeadlineBloc emit(state.copyWith(title: event.title)); } - void _onDescriptionChanged( - CreateHeadlineDescriptionChanged event, + void _onExcerptChanged( + CreateHeadlineExcerptChanged event, Emitter emit, ) { - emit(state.copyWith(description: event.description)); + emit(state.copyWith(excerpt: event.excerpt)); } void _onUrlChanged( @@ -108,11 +118,18 @@ class CreateHeadlineBloc emit(state.copyWith(source: () => event.source)); } - void _onCategoryChanged( - CreateHeadlineCategoryChanged event, + void _onTopicChanged( + CreateHeadlineTopicChanged event, Emitter emit, ) { - emit(state.copyWith(category: () => event.category)); + emit(state.copyWith(topic: () => event.topic)); + } + + void _onCountryChanged( + CreateHeadlineCountryChanged event, + Emitter emit, + ) { + emit(state.copyWith(eventCountry: () => event.country)); } void _onStatusChanged( @@ -137,15 +154,16 @@ class CreateHeadlineBloc try { final now = DateTime.now(); final newHeadline = Headline( + id: _uuid.v4(), title: state.title, - description: state.description.isNotEmpty ? state.description : null, - url: state.url.isNotEmpty ? state.url : null, - imageUrl: state.imageUrl.isNotEmpty ? state.imageUrl : null, - source: state.source, - publishedAt: now, + excerpt: state.excerpt, + url: state.url, + imageUrl: state.imageUrl, + source: state.source!, + eventCountry: state.eventCountry!, + topic: state.topic!, createdAt: now, updatedAt: now, - category: state.category, status: state.contentStatus, ); diff --git a/lib/content_management/bloc/create_headline/create_headline_event.dart b/lib/content_management/bloc/create_headline/create_headline_event.dart index 63d04c1..655eae9 100644 --- a/lib/content_management/bloc/create_headline/create_headline_event.dart +++ b/lib/content_management/bloc/create_headline/create_headline_event.dart @@ -21,12 +21,12 @@ final class CreateHeadlineTitleChanged extends CreateHeadlineEvent { List get props => [title]; } -/// Event for when the headline's description is changed. -final class CreateHeadlineDescriptionChanged extends CreateHeadlineEvent { - const CreateHeadlineDescriptionChanged(this.description); - final String description; +/// Event for when the headline's excerpt is changed. +final class CreateHeadlineExcerptChanged extends CreateHeadlineEvent { + const CreateHeadlineExcerptChanged(this.excerpt); + final String excerpt; @override - List get props => [description]; + List get props => [excerpt]; } /// Event for when the headline's URL is changed. @@ -53,12 +53,20 @@ final class CreateHeadlineSourceChanged extends CreateHeadlineEvent { List get props => [source]; } -/// Event for when the headline's category is changed. -final class CreateHeadlineCategoryChanged extends CreateHeadlineEvent { - const CreateHeadlineCategoryChanged(this.category); - final Category? category; +/// Event for when the headline's topic is changed. +final class CreateHeadlineTopicChanged extends CreateHeadlineEvent { + const CreateHeadlineTopicChanged(this.topic); + final Topic? topic; @override - List get props => [category]; + List get props => [topic]; +} + +/// Event for when the headline's country is changed. +final class CreateHeadlineCountryChanged extends CreateHeadlineEvent { + const CreateHeadlineCountryChanged(this.country); + final Country? country; + @override + List get props => [country]; } /// Event for when the headline's status is changed. diff --git a/lib/content_management/bloc/create_headline/create_headline_state.dart b/lib/content_management/bloc/create_headline/create_headline_state.dart index 605b078..26be5df 100644 --- a/lib/content_management/bloc/create_headline/create_headline_state.dart +++ b/lib/content_management/bloc/create_headline/create_headline_state.dart @@ -23,13 +23,15 @@ final class CreateHeadlineState extends Equatable { const CreateHeadlineState({ this.status = CreateHeadlineStatus.initial, this.title = '', - this.description = '', + this.excerpt = '', this.url = '', this.imageUrl = '', this.source, - this.category, + this.topic, + this.eventCountry, this.sources = const [], - this.categories = const [], + this.topics = const [], + this.countries = const [], this.contentStatus = ContentStatus.active, this.errorMessage, this.createdHeadline, @@ -37,30 +39,41 @@ final class CreateHeadlineState extends Equatable { final CreateHeadlineStatus status; final String title; - final String description; + final String excerpt; final String url; final String imageUrl; final Source? source; - final Category? category; + final Topic? topic; + final Country? eventCountry; final List sources; - final List categories; + final List topics; + final List countries; final ContentStatus contentStatus; final String? errorMessage; final Headline? createdHeadline; /// Returns true if the form is valid and can be submitted. - bool get isFormValid => title.isNotEmpty; + bool get isFormValid => + title.isNotEmpty && + excerpt.isNotEmpty && + url.isNotEmpty && + imageUrl.isNotEmpty && + source != null && + topic != null && + eventCountry != null; CreateHeadlineState copyWith({ CreateHeadlineStatus? status, String? title, - String? description, + String? excerpt, String? url, String? imageUrl, ValueGetter? source, - ValueGetter? category, + ValueGetter? topic, + ValueGetter? eventCountry, List? sources, - List? categories, + List? topics, + List? countries, ContentStatus? contentStatus, String? errorMessage, Headline? createdHeadline, @@ -68,13 +81,15 @@ final class CreateHeadlineState extends Equatable { return CreateHeadlineState( status: status ?? this.status, title: title ?? this.title, - description: description ?? this.description, + excerpt: excerpt ?? this.excerpt, url: url ?? this.url, imageUrl: imageUrl ?? this.imageUrl, source: source != null ? source() : this.source, - category: category != null ? category() : this.category, + topic: topic != null ? topic() : this.topic, + eventCountry: eventCountry != null ? eventCountry() : this.eventCountry, sources: sources ?? this.sources, - categories: categories ?? this.categories, + topics: topics ?? this.topics, + countries: countries ?? this.countries, contentStatus: contentStatus ?? this.contentStatus, errorMessage: errorMessage, createdHeadline: createdHeadline ?? this.createdHeadline, @@ -83,17 +98,19 @@ final class CreateHeadlineState extends Equatable { @override List get props => [ - status, - title, - description, - url, - imageUrl, - source, - category, - sources, - categories, - contentStatus, - errorMessage, - createdHeadline, - ]; + status, + title, + excerpt, + url, + imageUrl, + source, + topic, + eventCountry, + sources, + topics, + countries, + contentStatus, + errorMessage, + createdHeadline, + ]; } diff --git a/lib/content_management/bloc/create_source/create_source_bloc.dart b/lib/content_management/bloc/create_source/create_source_bloc.dart index 18db57f..7bb78aa 100644 --- a/lib/content_management/bloc/create_source/create_source_bloc.dart +++ b/lib/content_management/bloc/create_source/create_source_bloc.dart @@ -3,6 +3,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:uuid/uuid.dart'; part 'create_source_event.dart'; part 'create_source_state.dart'; @@ -29,6 +30,7 @@ class CreateSourceBloc extends Bloc { final HtDataRepository _sourcesRepository; final HtDataRepository _countriesRepository; + final _uuid = const Uuid(); Future _onDataLoaded( CreateSourceDataLoaded event, @@ -126,14 +128,15 @@ class CreateSourceBloc extends Bloc { try { final now = DateTime.now(); final newSource = Source( + id: _uuid.v4(), name: state.name, - description: state.description.isNotEmpty ? state.description : null, - url: state.url.isNotEmpty ? state.url : null, - sourceType: state.sourceType, - language: state.language.isNotEmpty ? state.language : null, + description: state.description, + url: state.url, + sourceType: state.sourceType!, + language: state.language, createdAt: now, updatedAt: now, - headquarters: state.headquarters, + headquarters: state.headquarters!, status: state.contentStatus, ); diff --git a/lib/content_management/bloc/create_source/create_source_state.dart b/lib/content_management/bloc/create_source/create_source_state.dart index 1818cb5..7f6bece 100644 --- a/lib/content_management/bloc/create_source/create_source_state.dart +++ b/lib/content_management/bloc/create_source/create_source_state.dart @@ -48,7 +48,13 @@ final class CreateSourceState extends Equatable { final Source? createdSource; /// Returns true if the form is valid and can be submitted. - bool get isFormValid => name.isNotEmpty; + bool get isFormValid => + name.isNotEmpty && + description.isNotEmpty && + url.isNotEmpty && + sourceType != null && + language.isNotEmpty && + headquarters != null; CreateSourceState copyWith({ CreateSourceStatus? status, diff --git a/lib/content_management/bloc/create_topic/create_topic_bloc.dart b/lib/content_management/bloc/create_topic/create_topic_bloc.dart new file mode 100644 index 0000000..f7f1746 --- /dev/null +++ b/lib/content_management/bloc/create_topic/create_topic_bloc.dart @@ -0,0 +1,117 @@ +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'; +import 'package:uuid/uuid.dart'; + +part 'create_topic_event.dart'; +part 'create_topic_state.dart'; + +/// A BLoC to manage the state of creating a new topic. +class CreateTopicBloc extends Bloc { + /// {@macro create_topic_bloc} + CreateTopicBloc({ + required HtDataRepository topicsRepository, + }) : _topicsRepository = topicsRepository, + super(const CreateTopicState()) { + on(_onNameChanged); + on(_onDescriptionChanged); + on(_onIconUrlChanged); + on(_onStatusChanged); + on(_onSubmitted); + } + + final HtDataRepository _topicsRepository; + final _uuid = const Uuid(); + + void _onNameChanged( + CreateTopicNameChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + name: event.name, + status: CreateTopicStatus.initial, + ), + ); + } + + void _onDescriptionChanged( + CreateTopicDescriptionChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + description: event.description, + status: CreateTopicStatus.initial, + ), + ); + } + + void _onIconUrlChanged( + CreateTopicIconUrlChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + iconUrl: event.iconUrl, + status: CreateTopicStatus.initial, + ), + ); + } + + void _onStatusChanged( + CreateTopicStatusChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + contentStatus: event.status, + status: CreateTopicStatus.initial, + ), + ); + } + + Future _onSubmitted( + CreateTopicSubmitted event, + Emitter emit, + ) async { + if (!state.isFormValid) return; + + emit(state.copyWith(status: CreateTopicStatus.submitting)); + try { + final now = DateTime.now(); + final newTopic = Topic( + id: _uuid.v4(), + name: state.name, + description: state.description, + iconUrl: state.iconUrl, + status: state.contentStatus, + createdAt: now, + updatedAt: now, + ); + + await _topicsRepository.create(item: newTopic); + emit( + state.copyWith( + status: CreateTopicStatus.success, + createdTopic: newTopic, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: CreateTopicStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: CreateTopicStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } +} diff --git a/lib/content_management/bloc/create_topic/create_topic_event.dart b/lib/content_management/bloc/create_topic/create_topic_event.dart new file mode 100644 index 0000000..7faa8bf --- /dev/null +++ b/lib/content_management/bloc/create_topic/create_topic_event.dart @@ -0,0 +1,47 @@ +part of 'create_topic_bloc.dart'; + +/// Base class for all events related to the [CreateTopicBloc]. +sealed class CreateTopicEvent extends Equatable { + const CreateTopicEvent(); + + @override + List get props => []; +} + +/// Event for when the topic's name is changed. +final class CreateTopicNameChanged extends CreateTopicEvent { + const CreateTopicNameChanged(this.name); + final String name; + @override + List get props => [name]; +} + +/// Event for when the topic's description is changed. +final class CreateTopicDescriptionChanged extends CreateTopicEvent { + const CreateTopicDescriptionChanged(this.description); + final String description; + @override + List get props => [description]; +} + +/// Event for when the topic's icon URL is changed. +final class CreateTopicIconUrlChanged extends CreateTopicEvent { + const CreateTopicIconUrlChanged(this.iconUrl); + final String iconUrl; + @override + List get props => [iconUrl]; +} + +/// Event for when the topic's status is changed. +final class CreateTopicStatusChanged extends CreateTopicEvent { + const CreateTopicStatusChanged(this.status); + + final ContentStatus status; + @override + List get props => [status]; +} + +/// Event to signal that the form should be submitted. +final class CreateTopicSubmitted extends CreateTopicEvent { + const CreateTopicSubmitted(); +} diff --git a/lib/content_management/bloc/create_category/create_category_state.dart b/lib/content_management/bloc/create_topic/create_topic_state.dart similarity index 52% rename from lib/content_management/bloc/create_category/create_category_state.dart rename to lib/content_management/bloc/create_topic/create_topic_state.dart index ec6a272..0fbc613 100644 --- a/lib/content_management/bloc/create_category/create_category_state.dart +++ b/lib/content_management/bloc/create_topic/create_topic_state.dart @@ -1,7 +1,7 @@ -part of 'create_category_bloc.dart'; +part of 'create_topic_bloc.dart'; -/// Represents the status of the create category operation. -enum CreateCategoryStatus { +/// Represents the status of the create topic operation. +enum CreateTopicStatus { /// Initial state. initial, @@ -15,59 +15,60 @@ enum CreateCategoryStatus { failure, } -/// The state for the [CreateCategoryBloc]. -final class CreateCategoryState extends Equatable { - /// {@macro create_category_state} - const CreateCategoryState({ - this.status = CreateCategoryStatus.initial, +/// The state for the [CreateTopicBloc]. +final class CreateTopicState extends Equatable { + /// {@macro create_topic_state} + const CreateTopicState({ + this.status = CreateTopicStatus.initial, this.name = '', this.description = '', this.iconUrl = '', this.contentStatus = ContentStatus.active, this.errorMessage, - this.createdCategory, + this.createdTopic, }); - final CreateCategoryStatus status; + final CreateTopicStatus status; final String name; final String description; final String iconUrl; final ContentStatus contentStatus; final String? errorMessage; - final Category? createdCategory; + final Topic? createdTopic; /// Returns true if the form is valid and can be submitted. - /// Based on the Category model, only the name is required. - bool get isFormValid => name.isNotEmpty; + /// Based on the Topic model, name, description, and iconUrl are required. + bool get isFormValid => + name.isNotEmpty && description.isNotEmpty && iconUrl.isNotEmpty; - CreateCategoryState copyWith({ - CreateCategoryStatus? status, + CreateTopicState copyWith({ + CreateTopicStatus? status, String? name, String? description, String? iconUrl, ContentStatus? contentStatus, String? errorMessage, - Category? createdCategory, + Topic? createdTopic, }) { - return CreateCategoryState( + return CreateTopicState( status: status ?? this.status, name: name ?? this.name, description: description ?? this.description, iconUrl: iconUrl ?? this.iconUrl, contentStatus: contentStatus ?? this.contentStatus, errorMessage: errorMessage, - createdCategory: createdCategory ?? this.createdCategory, + createdTopic: createdTopic ?? this.createdTopic, ); } @override List get props => [ - status, - name, - description, - iconUrl, - contentStatus, - errorMessage, - createdCategory, - ]; + status, + name, + description, + iconUrl, + contentStatus, + errorMessage, + createdTopic, + ]; } diff --git a/lib/content_management/bloc/edit_category/edit_category_bloc.dart b/lib/content_management/bloc/edit_category/edit_category_bloc.dart deleted file mode 100644 index 04d08ca..0000000 --- a/lib/content_management/bloc/edit_category/edit_category_bloc.dart +++ /dev/null @@ -1,167 +0,0 @@ -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'; - -part 'edit_category_event.dart'; -part 'edit_category_state.dart'; - -/// A BLoC to manage the state of editing a single category. -class EditCategoryBloc extends Bloc { - /// {@macro edit_category_bloc} - EditCategoryBloc({ - required HtDataRepository categoriesRepository, - required String categoryId, - }) : _categoriesRepository = categoriesRepository, - _categoryId = categoryId, - super(const EditCategoryState()) { - on(_onLoaded); - on(_onNameChanged); - on(_onDescriptionChanged); - on(_onIconUrlChanged); - on(_onStatusChanged); - on(_onSubmitted); - } - - final HtDataRepository _categoriesRepository; - final String _categoryId; - - Future _onLoaded( - EditCategoryLoaded event, - Emitter emit, - ) async { - emit(state.copyWith(status: EditCategoryStatus.loading)); - try { - final category = await _categoriesRepository.read(id: _categoryId); - emit( - state.copyWith( - status: EditCategoryStatus.initial, - initialCategory: category, - name: category.name, - description: category.description ?? '', - iconUrl: category.iconUrl ?? '', - contentStatus: category.status, - ), - ); - } on HtHttpException catch (e) { - emit( - state.copyWith( - status: EditCategoryStatus.failure, - errorMessage: e.message, - ), - ); - } catch (e) { - emit( - state.copyWith( - status: EditCategoryStatus.failure, - errorMessage: e.toString(), - ), - ); - } - } - - void _onNameChanged( - EditCategoryNameChanged event, - Emitter emit, - ) { - emit( - state.copyWith( - name: event.name, - // Reset status to allow for re-submission after a failure. - status: EditCategoryStatus.initial, - ), - ); - } - - void _onDescriptionChanged( - EditCategoryDescriptionChanged event, - Emitter emit, - ) { - emit( - state.copyWith( - description: event.description, - status: EditCategoryStatus.initial, - ), - ); - } - - void _onIconUrlChanged( - EditCategoryIconUrlChanged event, - Emitter emit, - ) { - emit( - state.copyWith( - iconUrl: event.iconUrl, - status: EditCategoryStatus.initial, - ), - ); - } - - void _onStatusChanged( - EditCategoryStatusChanged event, - Emitter emit, - ) { - emit( - state.copyWith( - contentStatus: event.status, - status: EditCategoryStatus.initial, - ), - ); - } - - Future _onSubmitted( - EditCategorySubmitted event, - Emitter emit, - ) async { - if (!state.isFormValid) return; - - // Safely access the initial category to prevent null errors. - final initialCategory = state.initialCategory; - if (initialCategory == null) { - emit( - state.copyWith( - status: EditCategoryStatus.failure, - errorMessage: 'Cannot update: Original category data not loaded.', - ), - ); - return; - } - - emit(state.copyWith(status: EditCategoryStatus.submitting)); - try { - // Use null for empty optional fields, which is cleaner for APIs. - final updatedCategory = initialCategory.copyWith( - name: state.name, - description: state.description.isNotEmpty ? state.description : null, - iconUrl: state.iconUrl.isNotEmpty ? state.iconUrl : null, - status: state.contentStatus, - updatedAt: DateTime.now(), - ); - - await _categoriesRepository.update( - id: _categoryId, - item: updatedCategory, - ); - emit( - state.copyWith( - status: EditCategoryStatus.success, - updatedCategory: updatedCategory, - ), - ); - } on HtHttpException catch (e) { - emit( - state.copyWith( - status: EditCategoryStatus.failure, - errorMessage: e.message, - ), - ); - } catch (e) { - emit( - state.copyWith( - status: EditCategoryStatus.failure, - errorMessage: e.toString(), - ), - ); - } - } -} diff --git a/lib/content_management/bloc/edit_category/edit_category_event.dart b/lib/content_management/bloc/edit_category/edit_category_event.dart deleted file mode 100644 index f830195..0000000 --- a/lib/content_management/bloc/edit_category/edit_category_event.dart +++ /dev/null @@ -1,59 +0,0 @@ -part of 'edit_category_bloc.dart'; - -/// Base class for all events related to the [EditCategoryBloc]. -sealed class EditCategoryEvent extends Equatable { - const EditCategoryEvent(); - - @override - List get props => []; -} - -/// Event to load the initial category data for editing. -final class EditCategoryLoaded extends EditCategoryEvent { - const EditCategoryLoaded(); -} - -/// Event triggered when the category name input changes. -final class EditCategoryNameChanged extends EditCategoryEvent { - const EditCategoryNameChanged(this.name); - - final String name; - - @override - List get props => [name]; -} - -/// Event triggered when the category description input changes. -final class EditCategoryDescriptionChanged extends EditCategoryEvent { - const EditCategoryDescriptionChanged(this.description); - - final String description; - - @override - List get props => [description]; -} - -/// Event triggered when the category icon URL input changes. -final class EditCategoryIconUrlChanged extends EditCategoryEvent { - const EditCategoryIconUrlChanged(this.iconUrl); - - final String iconUrl; - - @override - List get props => [iconUrl]; -} - -/// Event for when the category's status is changed. -final class EditCategoryStatusChanged extends EditCategoryEvent { - const EditCategoryStatusChanged(this.status); - - final ContentStatus status; - - @override - List get props => [status]; -} - -/// Event to submit the edited category data. -final class EditCategorySubmitted extends EditCategoryEvent { - const EditCategorySubmitted(); -} diff --git a/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart b/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart index c2951e7..3e4e503 100644 --- a/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart +++ b/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart @@ -1,6 +1,6 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart' hide Category; +import 'package:flutter/foundation.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart'; @@ -13,27 +13,31 @@ class EditHeadlineBloc extends Bloc { EditHeadlineBloc({ required HtDataRepository headlinesRepository, required HtDataRepository sourcesRepository, - required HtDataRepository categoriesRepository, + required HtDataRepository topicsRepository, + required HtDataRepository countriesRepository, required String headlineId, }) : _headlinesRepository = headlinesRepository, _sourcesRepository = sourcesRepository, - _categoriesRepository = categoriesRepository, + _topicsRepository = topicsRepository, + _countriesRepository = countriesRepository, _headlineId = headlineId, super(const EditHeadlineState()) { on(_onLoaded); on(_onTitleChanged); - on(_onDescriptionChanged); + on(_onExcerptChanged); on(_onUrlChanged); on(_onImageUrlChanged); on(_onSourceChanged); - on(_onCategoryChanged); + on(_onTopicChanged); + on(_onCountryChanged); on(_onStatusChanged); on(_onSubmitted); } final HtDataRepository _headlinesRepository; final HtDataRepository _sourcesRepository; - final HtDataRepository _categoriesRepository; + final HtDataRepository _topicsRepository; + final HtDataRepository _countriesRepository; final String _headlineId; Future _onLoaded( @@ -45,30 +49,34 @@ class EditHeadlineBloc extends Bloc { final [ headlineResponse, sourcesResponse, - categoriesResponse, + topicsResponse, + countriesResponse, ] = await Future.wait([ _headlinesRepository.read(id: _headlineId), _sourcesRepository.readAll(), - _categoriesRepository.readAll(), + _topicsRepository.readAll(), + _countriesRepository.readAll(), ]); final headline = headlineResponse as Headline; final sources = (sourcesResponse as PaginatedResponse).items; - final categories = - (categoriesResponse as PaginatedResponse).items; + final topics = (topicsResponse as PaginatedResponse).items; + final countries = (countriesResponse as PaginatedResponse).items; emit( state.copyWith( status: EditHeadlineStatus.initial, initialHeadline: headline, title: headline.title, - description: headline.description ?? '', - url: headline.url ?? '', - imageUrl: headline.imageUrl ?? '', + excerpt: headline.excerpt, + url: headline.url, + imageUrl: headline.imageUrl, source: () => headline.source, - category: () => headline.category, + topic: () => headline.topic, + eventCountry: () => headline.eventCountry, sources: sources, - categories: categories, + topics: topics, + countries: countries, contentStatus: headline.status, ), ); @@ -98,13 +106,13 @@ class EditHeadlineBloc extends Bloc { ); } - void _onDescriptionChanged( - EditHeadlineDescriptionChanged event, + void _onExcerptChanged( + EditHeadlineExcerptChanged event, Emitter emit, ) { emit( state.copyWith( - description: event.description, + excerpt: event.excerpt, status: EditHeadlineStatus.initial, ), ); @@ -141,13 +149,25 @@ class EditHeadlineBloc extends Bloc { ); } - void _onCategoryChanged( - EditHeadlineCategoryChanged event, + void _onTopicChanged( + EditHeadlineTopicChanged event, Emitter emit, ) { emit( state.copyWith( - category: () => event.category, + topic: () => event.topic, + status: EditHeadlineStatus.initial, + ), + ); + } + + void _onCountryChanged( + EditHeadlineCountryChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + eventCountry: () => event.country, status: EditHeadlineStatus.initial, ), ); @@ -186,11 +206,12 @@ class EditHeadlineBloc extends Bloc { try { final updatedHeadline = initialHeadline.copyWith( title: state.title, - description: state.description.isNotEmpty ? state.description : null, - url: state.url.isNotEmpty ? state.url : null, - imageUrl: state.imageUrl.isNotEmpty ? state.imageUrl : null, + excerpt: state.excerpt, + url: state.url, + imageUrl: state.imageUrl, source: state.source, - category: state.category, + topic: state.topic, + eventCountry: state.eventCountry, status: state.contentStatus, updatedAt: DateTime.now(), ); diff --git a/lib/content_management/bloc/edit_headline/edit_headline_event.dart b/lib/content_management/bloc/edit_headline/edit_headline_event.dart index 44e4e81..6bad7bd 100644 --- a/lib/content_management/bloc/edit_headline/edit_headline_event.dart +++ b/lib/content_management/bloc/edit_headline/edit_headline_event.dart @@ -21,12 +21,12 @@ final class EditHeadlineTitleChanged extends EditHeadlineEvent { List get props => [title]; } -/// Event for when the headline's description is changed. -final class EditHeadlineDescriptionChanged extends EditHeadlineEvent { - const EditHeadlineDescriptionChanged(this.description); - final String description; +/// Event for when the headline's excerpt is changed. +final class EditHeadlineExcerptChanged extends EditHeadlineEvent { + const EditHeadlineExcerptChanged(this.excerpt); + final String excerpt; @override - List get props => [description]; + List get props => [excerpt]; } /// Event for when the headline's URL is changed. @@ -53,12 +53,20 @@ final class EditHeadlineSourceChanged extends EditHeadlineEvent { List get props => [source]; } -/// Event for when the headline's category is changed. -final class EditHeadlineCategoryChanged extends EditHeadlineEvent { - const EditHeadlineCategoryChanged(this.category); - final Category? category; +/// Event for when the headline's topic is changed. +final class EditHeadlineTopicChanged extends EditHeadlineEvent { + const EditHeadlineTopicChanged(this.topic); + final Topic? topic; @override - List get props => [category]; + List get props => [topic]; +} + +/// Event for when the headline's country is changed. +final class EditHeadlineCountryChanged extends EditHeadlineEvent { + const EditHeadlineCountryChanged(this.country); + final Country? country; + @override + List get props => [country]; } /// Event for when the headline's status is changed. diff --git a/lib/content_management/bloc/edit_headline/edit_headline_state.dart b/lib/content_management/bloc/edit_headline/edit_headline_state.dart index 5cd017f..5cf98ea 100644 --- a/lib/content_management/bloc/edit_headline/edit_headline_state.dart +++ b/lib/content_management/bloc/edit_headline/edit_headline_state.dart @@ -24,13 +24,15 @@ final class EditHeadlineState extends Equatable { this.status = EditHeadlineStatus.initial, this.initialHeadline, this.title = '', - this.description = '', + this.excerpt = '', this.url = '', this.imageUrl = '', this.source, - this.category, + this.topic, + this.eventCountry, this.sources = const [], - this.categories = const [], + this.topics = const [], + this.countries = const [], this.contentStatus = ContentStatus.active, this.errorMessage, this.updatedHeadline, @@ -39,31 +41,42 @@ final class EditHeadlineState extends Equatable { final EditHeadlineStatus status; final Headline? initialHeadline; final String title; - final String description; + final String excerpt; final String url; final String imageUrl; final Source? source; - final Category? category; + final Topic? topic; + final Country? eventCountry; final List sources; - final List categories; + final List topics; + final List countries; final ContentStatus contentStatus; final String? errorMessage; final Headline? updatedHeadline; /// Returns true if the form is valid and can be submitted. - bool get isFormValid => title.isNotEmpty; + bool get isFormValid => + title.isNotEmpty && + excerpt.isNotEmpty && + url.isNotEmpty && + imageUrl.isNotEmpty && + source != null && + topic != null && + eventCountry != null; EditHeadlineState copyWith({ EditHeadlineStatus? status, Headline? initialHeadline, String? title, - String? description, + String? excerpt, String? url, String? imageUrl, ValueGetter? source, - ValueGetter? category, + ValueGetter? topic, + ValueGetter? eventCountry, List? sources, - List? categories, + List? topics, + List? countries, ContentStatus? contentStatus, String? errorMessage, Headline? updatedHeadline, @@ -72,13 +85,15 @@ final class EditHeadlineState extends Equatable { status: status ?? this.status, initialHeadline: initialHeadline ?? this.initialHeadline, title: title ?? this.title, - description: description ?? this.description, + excerpt: excerpt ?? this.excerpt, url: url ?? this.url, imageUrl: imageUrl ?? this.imageUrl, source: source != null ? source() : this.source, - category: category != null ? category() : this.category, + topic: topic != null ? topic() : this.topic, + eventCountry: eventCountry != null ? eventCountry() : this.eventCountry, sources: sources ?? this.sources, - categories: categories ?? this.categories, + topics: topics ?? this.topics, + countries: countries ?? this.countries, contentStatus: contentStatus ?? this.contentStatus, errorMessage: errorMessage, updatedHeadline: updatedHeadline ?? this.updatedHeadline, @@ -90,13 +105,15 @@ final class EditHeadlineState extends Equatable { status, initialHeadline, title, - description, + excerpt, url, imageUrl, source, - category, + topic, + eventCountry, sources, - categories, + topics, + countries, contentStatus, errorMessage, updatedHeadline, diff --git a/lib/content_management/bloc/edit_source/edit_source_bloc.dart b/lib/content_management/bloc/edit_source/edit_source_bloc.dart index 6da5cb8..45b6536 100644 --- a/lib/content_management/bloc/edit_source/edit_source_bloc.dart +++ b/lib/content_management/bloc/edit_source/edit_source_bloc.dart @@ -51,10 +51,10 @@ class EditSourceBloc extends Bloc { status: EditSourceStatus.initial, initialSource: source, name: source.name, - description: source.description ?? '', - url: source.url ?? '', + description: source.description, + url: source.url, sourceType: () => source.sourceType, - language: source.language ?? '', + language: source.language, headquarters: () => source.headquarters, contentStatus: source.status, countries: countries, @@ -172,10 +172,10 @@ class EditSourceBloc extends Bloc { try { final updatedSource = initialSource.copyWith( name: state.name, - description: state.description.isNotEmpty ? state.description : null, - url: state.url.isNotEmpty ? state.url : null, + description: state.description, + url: state.url, sourceType: state.sourceType, - language: state.language.isNotEmpty ? state.language : null, + language: state.language, headquarters: state.headquarters, status: state.contentStatus, updatedAt: DateTime.now(), diff --git a/lib/content_management/bloc/edit_source/edit_source_state.dart b/lib/content_management/bloc/edit_source/edit_source_state.dart index 52bd59b..b0a2805 100644 --- a/lib/content_management/bloc/edit_source/edit_source_state.dart +++ b/lib/content_management/bloc/edit_source/edit_source_state.dart @@ -49,7 +49,13 @@ final class EditSourceState extends Equatable { final Source? updatedSource; /// Returns true if the form is valid and can be submitted. - bool get isFormValid => name.isNotEmpty; + bool get isFormValid => + name.isNotEmpty && + description.isNotEmpty && + url.isNotEmpty && + sourceType != null && + language.isNotEmpty && + headquarters != null; EditSourceState copyWith({ EditSourceStatus? status, diff --git a/lib/content_management/bloc/edit_topic/edit_topic_bloc.dart b/lib/content_management/bloc/edit_topic/edit_topic_bloc.dart new file mode 100644 index 0000000..32b1a5f --- /dev/null +++ b/lib/content_management/bloc/edit_topic/edit_topic_bloc.dart @@ -0,0 +1,167 @@ +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'; + +part 'edit_topic_event.dart'; +part 'edit_topic_state.dart'; + +/// A BLoC to manage the state of editing a single topic. +class EditTopicBloc extends Bloc { + /// {@macro edit_topic_bloc} + EditTopicBloc({ + required HtDataRepository topicsRepository, + required String topicId, + }) : _topicsRepository = topicsRepository, + _topicId = topicId, + super(const EditTopicState()) { + on(_onLoaded); + on(_onNameChanged); + on(_onDescriptionChanged); + on(_onIconUrlChanged); + on(_onStatusChanged); + on(_onSubmitted); + } + + final HtDataRepository _topicsRepository; + final String _topicId; + + Future _onLoaded( + EditTopicLoaded event, + Emitter emit, + ) async { + emit(state.copyWith(status: EditTopicStatus.loading)); + try { + final topic = await _topicsRepository.read(id: _topicId); + emit( + state.copyWith( + status: EditTopicStatus.initial, + initialTopic: topic, + name: topic.name, + description: topic.description, + iconUrl: topic.iconUrl, + contentStatus: topic.status, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: EditTopicStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: EditTopicStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + void _onNameChanged( + EditTopicNameChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + name: event.name, + // Reset status to allow for re-submission after a failure. + status: EditTopicStatus.initial, + ), + ); + } + + void _onDescriptionChanged( + EditTopicDescriptionChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + description: event.description, + status: EditTopicStatus.initial, + ), + ); + } + + void _onIconUrlChanged( + EditTopicIconUrlChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + iconUrl: event.iconUrl, + status: EditTopicStatus.initial, + ), + ); + } + + void _onStatusChanged( + EditTopicStatusChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + contentStatus: event.status, + status: EditTopicStatus.initial, + ), + ); + } + + Future _onSubmitted( + EditTopicSubmitted event, + Emitter emit, + ) async { + if (!state.isFormValid) return; + + // Safely access the initial topic to prevent null errors. + final initialTopic = state.initialTopic; + if (initialTopic == null) { + emit( + state.copyWith( + status: EditTopicStatus.failure, + errorMessage: 'Cannot update: Original topic data not loaded.', + ), + ); + return; + } + + emit(state.copyWith(status: EditTopicStatus.submitting)); + try { + // Use null for empty optional fields, which is cleaner for APIs. + final updatedTopic = initialTopic.copyWith( + name: state.name, + description: state.description, + iconUrl: state.iconUrl, + status: state.contentStatus, + updatedAt: DateTime.now(), + ); + + await _topicsRepository.update( + id: _topicId, + item: updatedTopic, + ); + emit( + state.copyWith( + status: EditTopicStatus.success, + updatedTopic: updatedTopic, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: EditTopicStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: EditTopicStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } +} diff --git a/lib/content_management/bloc/edit_topic/edit_topic_event.dart b/lib/content_management/bloc/edit_topic/edit_topic_event.dart new file mode 100644 index 0000000..83b5fbb --- /dev/null +++ b/lib/content_management/bloc/edit_topic/edit_topic_event.dart @@ -0,0 +1,59 @@ +part of 'edit_topic_bloc.dart'; + +/// Base class for all events related to the [EditTopicBloc]. +sealed class EditTopicEvent extends Equatable { + const EditTopicEvent(); + + @override + List get props => []; +} + +/// Event to load the initial topic data for editing. +final class EditTopicLoaded extends EditTopicEvent { + const EditTopicLoaded(); +} + +/// Event triggered when the topic name input changes. +final class EditTopicNameChanged extends EditTopicEvent { + const EditTopicNameChanged(this.name); + + final String name; + + @override + List get props => [name]; +} + +/// Event triggered when the topic description input changes. +final class EditTopicDescriptionChanged extends EditTopicEvent { + const EditTopicDescriptionChanged(this.description); + + final String description; + + @override + List get props => [description]; +} + +/// Event triggered when the topic icon URL input changes. +final class EditTopicIconUrlChanged extends EditTopicEvent { + const EditTopicIconUrlChanged(this.iconUrl); + + final String iconUrl; + + @override + List get props => [iconUrl]; +} + +/// Event for when the topic's status is changed. +final class EditTopicStatusChanged extends EditTopicEvent { + const EditTopicStatusChanged(this.status); + + final ContentStatus status; + + @override + List get props => [status]; +} + +/// Event to submit the edited topic data. +final class EditTopicSubmitted extends EditTopicEvent { + const EditTopicSubmitted(); +} diff --git a/lib/content_management/bloc/edit_category/edit_category_state.dart b/lib/content_management/bloc/edit_topic/edit_topic_state.dart similarity index 52% rename from lib/content_management/bloc/edit_category/edit_category_state.dart rename to lib/content_management/bloc/edit_topic/edit_topic_state.dart index 0ec5343..7ee2b99 100644 --- a/lib/content_management/bloc/edit_category/edit_category_state.dart +++ b/lib/content_management/bloc/edit_topic/edit_topic_state.dart @@ -1,7 +1,7 @@ -part of 'edit_category_bloc.dart'; +part of 'edit_topic_bloc.dart'; -/// Represents the status of the edit category operation. -enum EditCategoryStatus { +/// Represents the status of the edit topic operation. +enum EditTopicStatus { /// Initial state, before any data is loaded. initial, @@ -18,62 +18,64 @@ enum EditCategoryStatus { submitting, } -/// The state for the [EditCategoryBloc]. -final class EditCategoryState extends Equatable { - const EditCategoryState({ - this.status = EditCategoryStatus.initial, - this.initialCategory, +/// The state for the [EditTopicBloc]. +final class EditTopicState extends Equatable { + const EditTopicState({ + this.status = EditTopicStatus.initial, + this.initialTopic, this.name = '', this.description = '', this.iconUrl = '', this.contentStatus = ContentStatus.active, this.errorMessage, - this.updatedCategory, + this.updatedTopic, }); - final EditCategoryStatus status; - final Category? initialCategory; + final EditTopicStatus status; + final Topic? initialTopic; final String name; final String description; final String iconUrl; final ContentStatus contentStatus; final String? errorMessage; - final Category? updatedCategory; + final Topic? updatedTopic; /// Returns true if the form is valid and can be submitted. - bool get isFormValid => name.isNotEmpty; + /// Based on the Topic model, name, description, and iconUrl are required. + bool get isFormValid => + name.isNotEmpty && description.isNotEmpty && iconUrl.isNotEmpty; - EditCategoryState copyWith({ - EditCategoryStatus? status, - Category? initialCategory, + EditTopicState copyWith({ + EditTopicStatus? status, + Topic? initialTopic, String? name, String? description, String? iconUrl, ContentStatus? contentStatus, String? errorMessage, - Category? updatedCategory, + Topic? updatedTopic, }) { - return EditCategoryState( + return EditTopicState( status: status ?? this.status, - initialCategory: initialCategory ?? this.initialCategory, + initialTopic: initialTopic ?? this.initialTopic, name: name ?? this.name, description: description ?? this.description, iconUrl: iconUrl ?? this.iconUrl, contentStatus: contentStatus ?? this.contentStatus, errorMessage: errorMessage ?? this.errorMessage, - updatedCategory: updatedCategory ?? this.updatedCategory, + updatedTopic: updatedTopic ?? this.updatedTopic, ); } @override List get props => [ - status, - initialCategory, - name, - description, - iconUrl, - contentStatus, - errorMessage, - updatedCategory, - ]; + status, + initialTopic, + name, + description, + iconUrl, + contentStatus, + errorMessage, + updatedTopic, + ]; } diff --git a/lib/content_management/view/content_management_page.dart b/lib/content_management/view/content_management_page.dart index d4f0305..1585bbe 100644 --- a/lib/content_management/view/content_management_page.dart +++ b/lib/content_management/view/content_management_page.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:ht_dashboard/content_management/bloc/content_management_bloc.dart'; -import 'package:ht_dashboard/content_management/view/categories_page.dart'; +import 'package:ht_dashboard/content_management/view/topics_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/l10n/l10n.dart'; @@ -84,7 +84,7 @@ class _ContentManagementPageState extends State isScrollable: true, tabs: [ Tab(text: l10n.headlines), - Tab(text: l10n.categories), + Tab(text: l10n.topics), Tab(text: l10n.sources), ], ), @@ -103,8 +103,8 @@ class _ContentManagementPageState extends State switch (currentTab) { case ContentManagementTab.headlines: context.goNamed(Routes.createHeadlineName); - case ContentManagementTab.categories: - context.goNamed(Routes.createCategoryName); + case ContentManagementTab.topics: + context.goNamed(Routes.createTopicName); case ContentManagementTab.sources: context.goNamed(Routes.createSourceName); } @@ -117,7 +117,7 @@ class _ContentManagementPageState extends State controller: _tabController, children: const [ HeadlinesPage(), - CategoriesPage(), + TopicPage(), SourcesPage(), ], ), diff --git a/lib/content_management/view/create_headline_page.dart b/lib/content_management/view/create_headline_page.dart index 1c5cc19..339a1cc 100644 --- a/lib/content_management/view/create_headline_page.dart +++ b/lib/content_management/view/create_headline_page.dart @@ -23,7 +23,8 @@ class CreateHeadlinePage extends StatelessWidget { create: (context) => CreateHeadlineBloc( headlinesRepository: context.read>(), sourcesRepository: context.read>(), - categoriesRepository: context.read>(), + topicsRepository: context.read>(), + countriesRepository: context.read>(), )..add(const CreateHeadlineDataLoaded()), child: const _CreateHeadlineView(), ); @@ -111,7 +112,8 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { if (state.status == CreateHeadlineStatus.failure && state.sources.isEmpty && - state.categories.isEmpty) { + state.topics.isEmpty && + state.countries.isEmpty) { return FailureStateWidget( message: state.errorMessage ?? l10n.unknownError, onRetry: () => context.read().add( @@ -140,15 +142,15 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { ), const SizedBox(height: AppSpacing.lg), TextFormField( - initialValue: state.description, + initialValue: state.excerpt, decoration: InputDecoration( - labelText: l10n.description, + labelText: l10n.excerpt, border: const OutlineInputBorder(), ), maxLines: 3, onChanged: (value) => context .read() - .add(CreateHeadlineDescriptionChanged(value)), + .add(CreateHeadlineExcerptChanged(value)), ), const SizedBox(height: AppSpacing.lg), TextFormField( @@ -193,24 +195,44 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { .add(CreateHeadlineSourceChanged(value)), ), const SizedBox(height: AppSpacing.lg), - DropdownButtonFormField( - value: state.category, + DropdownButtonFormField( + value: state.topic, decoration: InputDecoration( - labelText: l10n.categoryName, + labelText: l10n.topicName, border: const OutlineInputBorder(), ), items: [ DropdownMenuItem(value: null, child: Text(l10n.none)), - ...state.categories.map( - (category) => DropdownMenuItem( - value: category, - child: Text(category.name), + ...state.topics.map( + (topic) => DropdownMenuItem( + value: topic, + child: Text(topic.name), ), ), ], onChanged: (value) => context .read() - .add(CreateHeadlineCategoryChanged(value)), + .add(CreateHeadlineTopicChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + DropdownButtonFormField( + value: state.eventCountry, + decoration: InputDecoration( + labelText: l10n.countryName, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem(value: null, child: Text(l10n.none)), + ...state.countries.map( + (country) => DropdownMenuItem( + value: country, + child: Text(country.name), + ), + ), + ], + onChanged: (value) => context + .read() + .add(CreateHeadlineCountryChanged(value)), ), const SizedBox(height: AppSpacing.lg), DropdownButtonFormField( diff --git a/lib/content_management/view/create_category_page.dart b/lib/content_management/view/create_topic_page.dart similarity index 66% rename from lib/content_management/view/create_category_page.dart rename to lib/content_management/view/create_topic_page.dart index bb54621..adc2d0c 100644 --- a/lib/content_management/view/create_category_page.dart +++ b/lib/content_management/view/create_topic_page.dart @@ -2,40 +2,40 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:ht_dashboard/content_management/bloc/content_management_bloc.dart'; -import 'package:ht_dashboard/content_management/bloc/create_category/create_category_bloc.dart'; +import 'package:ht_dashboard/content_management/bloc/create_topic/create_topic_bloc.dart'; import 'package:ht_dashboard/l10n/l10n.dart'; import 'package:ht_dashboard/shared/constants/pagination_constants.dart'; import 'package:ht_dashboard/shared/shared.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart'; -/// {@template create_category_page} -/// A page for creating a new category. -/// It uses a [BlocProvider] to create and provide a [CreateCategoryBloc]. +/// {@template create_topic_page} +/// A page for creating a new topic. +/// It uses a [BlocProvider] to create and provide a [CreateTopicBloc]. /// {@endtemplate} -class CreateCategoryPage extends StatelessWidget { - /// {@macro create_category_page} - const CreateCategoryPage({super.key}); +class CreateTopicPage extends StatelessWidget { + /// {@macro create_topic_page} + const CreateTopicPage({super.key}); @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => CreateCategoryBloc( - categoriesRepository: context.read>(), + create: (context) => CreateTopicBloc( + topicsRepository: context.read>(), ), - child: const _CreateCategoryView(), + child: const _CreateTopicView(), ); } } -class _CreateCategoryView extends StatefulWidget { - const _CreateCategoryView(); +class _CreateTopicView extends StatefulWidget { + const _CreateTopicView(); @override - State<_CreateCategoryView> createState() => _CreateCategoryViewState(); + State<_CreateTopicView> createState() => _CreateTopicViewState(); } -class _CreateCategoryViewState extends State<_CreateCategoryView> { +class _CreateTopicViewState extends State<_CreateTopicView> { final _formKey = GlobalKey(); @override @@ -43,11 +43,11 @@ class _CreateCategoryViewState extends State<_CreateCategoryView> { final l10n = context.l10n; return Scaffold( appBar: AppBar( - title: Text(l10n.createCategory), + title: Text(l10n.createTopic), actions: [ - BlocBuilder( + BlocBuilder( builder: (context, state) { - if (state.status == CreateCategoryStatus.submitting) { + if (state.status == CreateTopicStatus.submitting) { return const Padding( padding: EdgeInsets.only(right: AppSpacing.lg), child: SizedBox( @@ -61,8 +61,8 @@ class _CreateCategoryViewState extends State<_CreateCategoryView> { icon: const Icon(Icons.save), tooltip: l10n.saveChanges, onPressed: state.isFormValid - ? () => context.read().add( - const CreateCategorySubmitted(), + ? () => context.read().add( + const CreateTopicSubmitted(), ) : null, ); @@ -70,24 +70,24 @@ class _CreateCategoryViewState extends State<_CreateCategoryView> { ), ], ), - body: BlocConsumer( + body: BlocConsumer( listenWhen: (previous, current) => previous.status != current.status, listener: (context, state) { - if (state.status == CreateCategoryStatus.success && - state.createdCategory != null && + if (state.status == CreateTopicStatus.success && + state.createdTopic != null && ModalRoute.of(context)!.isCurrent) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( - SnackBar(content: Text(l10n.categoryCreatedSuccessfully)), + SnackBar(content: Text(l10n.topicCreatedSuccessfully)), ); context.read().add( - // Refresh the list to show the new category - const LoadCategoriesRequested(limit: kDefaultRowsPerPage), + // Refresh the list to show the new topic + const LoadTopicsRequested(limit: kDefaultRowsPerPage), ); context.pop(); } - if (state.status == CreateCategoryStatus.failure) { + if (state.status == CreateTopicStatus.failure) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( @@ -110,12 +110,12 @@ class _CreateCategoryViewState extends State<_CreateCategoryView> { TextFormField( initialValue: state.name, decoration: InputDecoration( - labelText: l10n.categoryName, + labelText: l10n.topicName, border: const OutlineInputBorder(), ), - onChanged: (value) => context - .read() - .add(CreateCategoryNameChanged(value)), + onChanged: (value) => context.read().add( + CreateTopicNameChanged(value), + ), ), const SizedBox(height: AppSpacing.lg), TextFormField( @@ -125,9 +125,9 @@ class _CreateCategoryViewState extends State<_CreateCategoryView> { border: const OutlineInputBorder(), ), maxLines: 3, - onChanged: (value) => context - .read() - .add(CreateCategoryDescriptionChanged(value)), + onChanged: (value) => context.read().add( + CreateTopicDescriptionChanged(value), + ), ), const SizedBox(height: AppSpacing.lg), TextFormField( @@ -136,9 +136,9 @@ class _CreateCategoryViewState extends State<_CreateCategoryView> { labelText: l10n.iconUrl, border: const OutlineInputBorder(), ), - onChanged: (value) => context - .read() - .add(CreateCategoryIconUrlChanged(value)), + onChanged: (value) => context.read().add( + CreateTopicIconUrlChanged(value), + ), ), const SizedBox(height: AppSpacing.lg), DropdownButtonFormField( @@ -155,8 +155,8 @@ class _CreateCategoryViewState extends State<_CreateCategoryView> { }).toList(), onChanged: (value) { if (value == null) return; - context.read().add( - CreateCategoryStatusChanged(value), + context.read().add( + CreateTopicStatusChanged(value), ); }, ), diff --git a/lib/content_management/view/edit_headline_page.dart b/lib/content_management/view/edit_headline_page.dart index 3f90c5b..6a57aec 100644 --- a/lib/content_management/view/edit_headline_page.dart +++ b/lib/content_management/view/edit_headline_page.dart @@ -25,7 +25,8 @@ class EditHeadlinePage extends StatelessWidget { create: (context) => EditHeadlineBloc( headlinesRepository: context.read>(), sourcesRepository: context.read>(), - categoriesRepository: context.read>(), + topicsRepository: context.read>(), + countriesRepository: context.read>(), headlineId: headlineId, )..add(const EditHeadlineLoaded()), child: const _EditHeadlineView(), @@ -43,7 +44,7 @@ class _EditHeadlineView extends StatefulWidget { class _EditHeadlineViewState extends State<_EditHeadlineView> { final _formKey = GlobalKey(); late final TextEditingController _titleController; - late final TextEditingController _descriptionController; + late final TextEditingController _excerptController; late final TextEditingController _urlController; late final TextEditingController _imageUrlController; @@ -52,7 +53,7 @@ class _EditHeadlineViewState extends State<_EditHeadlineView> { super.initState(); final state = context.read().state; _titleController = TextEditingController(text: state.title); - _descriptionController = TextEditingController(text: state.description); + _excerptController = TextEditingController(text: state.excerpt); _urlController = TextEditingController(text: state.url); _imageUrlController = TextEditingController(text: state.imageUrl); } @@ -60,7 +61,7 @@ class _EditHeadlineViewState extends State<_EditHeadlineView> { @override void dispose() { _titleController.dispose(); - _descriptionController.dispose(); + _excerptController.dispose(); _urlController.dispose(); _imageUrlController.dispose(); super.dispose(); @@ -130,7 +131,7 @@ class _EditHeadlineViewState extends State<_EditHeadlineView> { } if (state.initialHeadline != null) { _titleController.text = state.title; - _descriptionController.text = state.description; + _excerptController.text = state.excerpt; _urlController.text = state.url; _imageUrlController.text = state.imageUrl; } @@ -167,14 +168,25 @@ class _EditHeadlineViewState extends State<_EditHeadlineView> { } } - Category? selectedCategory; - if (state.category != null) { + Topic? selectedTopic; + if (state.topic != null) { try { - selectedCategory = state.categories.firstWhere( - (c) => c.id == state.category!.id, + selectedTopic = state.topics.firstWhere( + (t) => t.id == state.topic!.id, ); } catch (_) { - selectedCategory = null; + selectedTopic = null; + } + } + + Country? selectedCountry; + if (state.eventCountry != null) { + try { + selectedCountry = state.countries.firstWhere( + (c) => c.id == state.eventCountry!.id, + ); + } catch (_) { + selectedCountry = null; } } @@ -198,15 +210,15 @@ class _EditHeadlineViewState extends State<_EditHeadlineView> { ), const SizedBox(height: AppSpacing.lg), TextFormField( - controller: _descriptionController, + controller: _excerptController, decoration: InputDecoration( - labelText: l10n.description, + labelText: l10n.excerpt, border: const OutlineInputBorder(), ), maxLines: 3, onChanged: (value) => context .read() - .add(EditHeadlineDescriptionChanged(value)), + .add(EditHeadlineExcerptChanged(value)), ), const SizedBox(height: AppSpacing.lg), TextFormField( @@ -251,24 +263,44 @@ class _EditHeadlineViewState extends State<_EditHeadlineView> { .add(EditHeadlineSourceChanged(value)), ), const SizedBox(height: AppSpacing.lg), - DropdownButtonFormField( - value: selectedCategory, + DropdownButtonFormField( + value: selectedTopic, + decoration: InputDecoration( + labelText: l10n.topicName, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem(value: null, child: Text(l10n.none)), + ...state.topics.map( + (topic) => DropdownMenuItem( + value: topic, + child: Text(topic.name), + ), + ), + ], + onChanged: (value) => context + .read() + .add(EditHeadlineTopicChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + DropdownButtonFormField( + value: selectedCountry, decoration: InputDecoration( - labelText: l10n.categoryName, + labelText: l10n.countryName, border: const OutlineInputBorder(), ), items: [ DropdownMenuItem(value: null, child: Text(l10n.none)), - ...state.categories.map( - (category) => DropdownMenuItem( - value: category, - child: Text(category.name), + ...state.countries.map( + (country) => DropdownMenuItem( + value: country, + child: Text(country.name), ), ), ], onChanged: (value) => context .read() - .add(EditHeadlineCategoryChanged(value)), + .add(EditHeadlineCountryChanged(value)), ), const SizedBox(height: AppSpacing.lg), DropdownButtonFormField( diff --git a/lib/content_management/view/edit_category_page.dart b/lib/content_management/view/edit_topic_page.dart similarity index 67% rename from lib/content_management/view/edit_category_page.dart rename to lib/content_management/view/edit_topic_page.dart index 2a88896..66c1320 100644 --- a/lib/content_management/view/edit_category_page.dart +++ b/lib/content_management/view/edit_topic_page.dart @@ -2,43 +2,43 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:ht_dashboard/content_management/bloc/content_management_bloc.dart'; -import 'package:ht_dashboard/content_management/bloc/edit_category/edit_category_bloc.dart'; +import 'package:ht_dashboard/content_management/bloc/edit_topic/edit_topic_bloc.dart'; import 'package:ht_dashboard/l10n/l10n.dart'; import 'package:ht_dashboard/shared/shared.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart'; -/// {@template edit_category_page} -/// A page for editing an existing category. -/// It uses a [BlocProvider] to create and provide an [EditCategoryBloc]. +/// {@template edit_topic_page} +/// A page for editing an existing topic. +/// It uses a [BlocProvider] to create and provide an [EditTopicBloc]. /// {@endtemplate} -class EditCategoryPage extends StatelessWidget { - /// {@macro edit_category_page} - const EditCategoryPage({required this.categoryId, super.key}); +class EditTopicPage extends StatelessWidget { + /// {@macro edit_topic_page} + const EditTopicPage({required this.topicId, super.key}); - /// The ID of the category to be edited. - final String categoryId; + /// The ID of the topic to be edited. + final String topicId; @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => EditCategoryBloc( - categoriesRepository: context.read>(), - categoryId: categoryId, - )..add(const EditCategoryLoaded()), - child: const _EditCategoryView(), + create: (context) => EditTopicBloc( + topicsRepository: context.read>(), + topicId: topicId, + )..add(const EditTopicLoaded()), + child: const _EditTopicView(), ); } } -class _EditCategoryView extends StatefulWidget { - const _EditCategoryView(); +class _EditTopicView extends StatefulWidget { + const _EditTopicView(); @override - State<_EditCategoryView> createState() => _EditCategoryViewState(); + State<_EditTopicView> createState() => _EditTopicViewState(); } -class _EditCategoryViewState extends State<_EditCategoryView> { +class _EditTopicViewState extends State<_EditTopicView> { final _formKey = GlobalKey(); late final TextEditingController _nameController; late final TextEditingController _descriptionController; @@ -47,7 +47,7 @@ class _EditCategoryViewState extends State<_EditCategoryView> { @override void initState() { super.initState(); - final state = context.read().state; + final state = context.read().state; _nameController = TextEditingController(text: state.name); _descriptionController = TextEditingController(text: state.description); _iconUrlController = TextEditingController(text: state.iconUrl); @@ -66,11 +66,11 @@ class _EditCategoryViewState extends State<_EditCategoryView> { final l10n = context.l10n; return Scaffold( appBar: AppBar( - title: Text(l10n.editCategory), + title: Text(l10n.editTopic), actions: [ - BlocBuilder( + BlocBuilder( builder: (context, state) { - if (state.status == EditCategoryStatus.submitting) { + if (state.status == EditTopicStatus.submitting) { return const Padding( padding: EdgeInsets.only(right: AppSpacing.lg), child: SizedBox( @@ -84,8 +84,8 @@ class _EditCategoryViewState extends State<_EditCategoryView> { icon: const Icon(Icons.save), tooltip: l10n.saveChanges, onPressed: state.isFormValid - ? () => context.read().add( - const EditCategorySubmitted(), + ? () => context.read().add( + const EditTopicSubmitted(), ) : null, ); @@ -93,26 +93,25 @@ class _EditCategoryViewState extends State<_EditCategoryView> { ), ], ), - body: BlocConsumer( + body: BlocConsumer( listenWhen: (previous, current) => previous.status != current.status || - previous.initialCategory != current.initialCategory, + previous.initialTopic != current.initialTopic, listener: (context, state) { - if (state.status == EditCategoryStatus.success && - state.updatedCategory != null && + if (state.status == EditTopicStatus.success && + state.updatedTopic != null && ModalRoute.of(context)!.isCurrent) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( - // TODO(l10n): Localize this message. - const SnackBar(content: Text('Category updated successfully.')), + SnackBar(content: Text(l10n.topicUpdatedSuccessfully)), ); context.read().add( - CategoryUpdated(state.updatedCategory!), + TopicUpdated(state.updatedTopic!), ); context.pop(); } - if (state.status == EditCategoryStatus.failure) { + if (state.status == EditTopicStatus.failure) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( @@ -122,28 +121,27 @@ class _EditCategoryViewState extends State<_EditCategoryView> { ), ); } - if (state.initialCategory != null) { + if (state.initialTopic != null) { _nameController.text = state.name; _descriptionController.text = state.description; _iconUrlController.text = state.iconUrl; } }, builder: (context, state) { - if (state.status == EditCategoryStatus.loading) { + if (state.status == EditTopicStatus.loading) { return LoadingStateWidget( - icon: Icons.category, - // TODO(l10n): Localize this message. - headline: 'Loading Category...', + icon: Icons.topic, + headline: l10n.loadingTopic, subheadline: l10n.pleaseWait, ); } - if (state.status == EditCategoryStatus.failure && - state.initialCategory == null) { + if (state.status == EditTopicStatus.failure && + state.initialTopic == null) { return FailureStateWidget( message: state.errorMessage ?? l10n.unknownError, - onRetry: () => context.read().add( - const EditCategoryLoaded(), + onRetry: () => context.read().add( + const EditTopicLoaded(), ), ); } @@ -159,12 +157,12 @@ class _EditCategoryViewState extends State<_EditCategoryView> { TextFormField( controller: _nameController, decoration: InputDecoration( - labelText: l10n.categoryName, + labelText: l10n.topicName, border: const OutlineInputBorder(), ), onChanged: (value) => context - .read() - .add(EditCategoryNameChanged(value)), + .read() + .add(EditTopicNameChanged(value)), ), const SizedBox(height: AppSpacing.lg), TextFormField( @@ -175,8 +173,8 @@ class _EditCategoryViewState extends State<_EditCategoryView> { ), maxLines: 3, onChanged: (value) => context - .read() - .add(EditCategoryDescriptionChanged(value)), + .read() + .add(EditTopicDescriptionChanged(value)), ), const SizedBox(height: AppSpacing.lg), TextFormField( @@ -186,8 +184,8 @@ class _EditCategoryViewState extends State<_EditCategoryView> { border: const OutlineInputBorder(), ), onChanged: (value) => context - .read() - .add(EditCategoryIconUrlChanged(value)), + .read() + .add(EditTopicIconUrlChanged(value)), ), const SizedBox(height: AppSpacing.lg), DropdownButtonFormField( @@ -204,8 +202,8 @@ class _EditCategoryViewState extends State<_EditCategoryView> { }).toList(), onChanged: (value) { if (value == null) return; - context.read().add( - EditCategoryStatusChanged(value), + context.read().add( + EditTopicStatusChanged(value), ); }, ), diff --git a/lib/content_management/view/headlines_page.dart b/lib/content_management/view/headlines_page.dart index 55beb51..97788f5 100644 --- a/lib/content_management/view/headlines_page.dart +++ b/lib/content_management/view/headlines_page.dart @@ -169,14 +169,12 @@ class _HeadlinesDataSource extends DataTableSource { }, cells: [ DataCell(Text(headline.title)), - DataCell(Text(headline.source?.name ?? l10n.unknown)), + DataCell(Text(headline.source.name)), DataCell(Text(headline.status.l10n(context))), DataCell( Text( - headline.updatedAt != null - // TODO(fulleni): Make date format configurable by admin. - ? DateFormat('dd-MM-yyyy').format(headline.updatedAt!.toLocal()) - : l10n.notAvailable, + // TODO(fulleni): Make date format configurable by admin. + DateFormat('dd-MM-yyyy').format(headline.updatedAt.toLocal()), ), ), DataCell( diff --git a/lib/content_management/view/sources_page.dart b/lib/content_management/view/sources_page.dart index 2cbf184..a353755 100644 --- a/lib/content_management/view/sources_page.dart +++ b/lib/content_management/view/sources_page.dart @@ -168,14 +168,12 @@ class _SourcesDataSource extends DataTableSource { }, cells: [ DataCell(Text(source.name)), - DataCell(Text(source.sourceType?.localizedName(l10n) ?? l10n.unknown)), + DataCell(Text(source.sourceType.localizedName(l10n))), DataCell(Text(source.status.l10n(context))), DataCell( Text( - source.updatedAt != null - // TODO(fulleni): Make date format configurable by admin. - ? DateFormat('dd-MM-yyyy').format(source.updatedAt!.toLocal()) - : l10n.notAvailable, + // TODO(fulleni): Make date format configurable by admin. + DateFormat('dd-MM-yyyy').format(source.updatedAt.toLocal()), ), ), DataCell( diff --git a/lib/content_management/view/categories_page.dart b/lib/content_management/view/topics_page.dart similarity index 65% rename from lib/content_management/view/categories_page.dart rename to lib/content_management/view/topics_page.dart index 13cf504..9f82375 100644 --- a/lib/content_management/view/categories_page.dart +++ b/lib/content_management/view/topics_page.dart @@ -11,23 +11,23 @@ import 'package:ht_dashboard/shared/shared.dart'; import 'package:ht_shared/ht_shared.dart'; import 'package:intl/intl.dart'; -/// {@template categories_page} -/// A page for displaying and managing Categories in a tabular format. +/// {@template topics_page} +/// A page for displaying and managing Topics in a tabular format. /// {@endtemplate} -class CategoriesPage extends StatefulWidget { - /// {@macro categories_page} - const CategoriesPage({super.key}); +class TopicPage extends StatefulWidget { + /// {@macro topics_page} + const TopicPage({super.key}); @override - State createState() => _CategoriesPageState(); + State createState() => _TopicPageState(); } -class _CategoriesPageState extends State { +class _TopicPageState extends State { @override void initState() { super.initState(); context.read().add( - const LoadCategoriesRequested(limit: kDefaultRowsPerPage), + const LoadTopicsRequested(limit: kDefaultRowsPerPage), ); } @@ -38,34 +38,34 @@ class _CategoriesPageState extends State { padding: const EdgeInsets.all(AppSpacing.lg), child: BlocBuilder( builder: (context, state) { - if (state.categoriesStatus == ContentManagementStatus.loading && - state.categories.isEmpty) { + if (state.topicsStatus == ContentManagementStatus.loading && + state.topics.isEmpty) { return LoadingStateWidget( - icon: Icons.category, - headline: l10n.loadingCategories, + icon: Icons.topic, + headline: l10n.loadingTopics, subheadline: l10n.pleaseWait, ); } - if (state.categoriesStatus == ContentManagementStatus.failure) { + if (state.topicsStatus == ContentManagementStatus.failure) { return FailureStateWidget( message: state.errorMessage ?? l10n.unknownError, onRetry: () => context.read().add( - const LoadCategoriesRequested(limit: kDefaultRowsPerPage), + const LoadTopicsRequested(limit: kDefaultRowsPerPage), ), ); } - if (state.categories.isEmpty) { + if (state.topics.isEmpty) { return Center( - child: Text(l10n.noCategoriesFound), + child: Text(l10n.noTopicsFound), ); } return PaginatedDataTable2( columns: [ DataColumn2( - label: Text(l10n.categoryName), + label: Text(l10n.topicName), size: ColumnSize.L, ), DataColumn2( @@ -82,30 +82,29 @@ class _CategoriesPageState extends State { fixedWidth: 120, ), ], - source: _CategoriesDataSource( + source: _TopicsDataSource( context: context, - categories: state.categories, - isLoading: - state.categoriesStatus == ContentManagementStatus.loading, - hasMore: state.categoriesHasMore, + topics: state.topics, + isLoading: state.topicsStatus == ContentManagementStatus.loading, + hasMore: state.topicsHasMore, l10n: l10n, ), rowsPerPage: kDefaultRowsPerPage, availableRowsPerPage: const [kDefaultRowsPerPage], onPageChanged: (pageIndex) { final newOffset = pageIndex * kDefaultRowsPerPage; - if (newOffset >= state.categories.length && - state.categoriesHasMore && - state.categoriesStatus != ContentManagementStatus.loading) { + if (newOffset >= state.topics.length && + state.topicsHasMore && + state.topicsStatus != ContentManagementStatus.loading) { context.read().add( - LoadCategoriesRequested( - startAfterId: state.categoriesCursor, + LoadTopicsRequested( + startAfterId: state.topicsCursor, limit: kDefaultRowsPerPage, ), ); } }, - empty: Center(child: Text(l10n.noCategoriesFound)), + empty: Center(child: Text(l10n.noTopicsFound)), showCheckboxColumn: false, showFirstLastButtons: true, fit: FlexFit.tight, @@ -120,24 +119,24 @@ class _CategoriesPageState extends State { } } -class _CategoriesDataSource extends DataTableSource { - _CategoriesDataSource({ +class _TopicsDataSource extends DataTableSource { + _TopicsDataSource({ required this.context, - required this.categories, + required this.topics, required this.isLoading, required this.hasMore, required this.l10n, }); final BuildContext context; - final List categories; + final List topics; final bool isLoading; final bool hasMore; final AppLocalizations l10n; @override DataRow? getRow(int index) { - if (index >= categories.length) { + if (index >= topics.length) { // This can happen if hasMore is true and the user is on the last page. // If we are loading, show a spinner. Otherwise, we've reached the end. if (isLoading) { @@ -150,25 +149,23 @@ class _CategoriesDataSource extends DataTableSource { } return null; } - final category = categories[index]; + final topic = topics[index]; return DataRow2( onSelectChanged: (selected) { if (selected ?? false) { context.goNamed( - Routes.editCategoryName, - pathParameters: {'id': category.id}, + Routes.editTopicName, + pathParameters: {'id': topic.id}, ); } }, cells: [ - DataCell(Text(category.name)), - DataCell(Text(category.status.l10n(context))), + DataCell(Text(topic.name)), + DataCell(Text(topic.status.l10n(context))), DataCell( Text( - category.updatedAt != null - // TODO(fulleni): Make date format configurable by admin. - ? DateFormat('dd-MM-yyyy').format(category.updatedAt!.toLocal()) - : l10n.notAvailable, + // TODO(fulleni): Make date format configurable by admin. + DateFormat('dd-MM-yyyy').format(topic.updatedAt.toLocal()), ), ), DataCell( @@ -179,8 +176,8 @@ class _CategoriesDataSource extends DataTableSource { onPressed: () { // Navigate to edit page context.goNamed( - Routes.editCategoryName, // Assuming an edit route exists - pathParameters: {'id': category.id}, + Routes.editTopicName, // Assuming an edit route exists + pathParameters: {'id': topic.id}, ); }, ), @@ -189,7 +186,7 @@ class _CategoriesDataSource extends DataTableSource { onPressed: () { // Dispatch delete event context.read().add( - DeleteCategoryRequested(category.id), + DeleteTopicRequested(topic.id), ); }, ), @@ -211,11 +208,9 @@ class _CategoriesDataSource extends DataTableSource { if (hasMore) { // When loading, we show an extra row for the spinner. // Otherwise, we just indicate that there are more rows. - return isLoading - ? categories.length + 1 - : categories.length + kDefaultRowsPerPage; + return isLoading ? topics.length + 1 : topics.length + kDefaultRowsPerPage; } - return categories.length; + return topics.length; } @override diff --git a/lib/dashboard/bloc/dashboard_bloc.dart b/lib/dashboard/bloc/dashboard_bloc.dart index 5bfda30..7a3e6cf 100644 --- a/lib/dashboard/bloc/dashboard_bloc.dart +++ b/lib/dashboard/bloc/dashboard_bloc.dart @@ -11,7 +11,7 @@ class DashboardBloc extends Bloc { /// {@macro dashboard_bloc} DashboardBloc({ required HtDataRepository dashboardSummaryRepository, - required HtDataRepository appConfigRepository, + required HtDataRepository appConfigRepository, required HtDataRepository headlinesRepository, }) : _dashboardSummaryRepository = dashboardSummaryRepository, _appConfigRepository = appConfigRepository, @@ -21,7 +21,7 @@ class DashboardBloc extends Bloc { } final HtDataRepository _dashboardSummaryRepository; - final HtDataRepository _appConfigRepository; + final HtDataRepository _appConfigRepository; final HtDataRepository _headlinesRepository; Future _onDashboardSummaryLoaded( @@ -39,14 +39,13 @@ class DashboardBloc extends Bloc { _dashboardSummaryRepository.read(id: 'dashboard_summary'), _appConfigRepository.read(id: 'app_config'), _headlinesRepository.readAll( - sortBy: 'createdAt', - sortOrder: SortOrder.desc, - limit: 5, + pagination: const PaginationOptions(limit: 5), + sort: const [SortOption('createdAt', SortOrder.desc)], ), ]); final summary = summaryResponse as DashboardSummary; - final appConfig = appConfigResponse as AppConfig; + final appConfig = appConfigResponse as RemoteConfig; final recentHeadlines = (recentHeadlinesResponse as PaginatedResponse).items; emit( diff --git a/lib/dashboard/bloc/dashboard_state.dart b/lib/dashboard/bloc/dashboard_state.dart index 0bd718e..7f37199 100644 --- a/lib/dashboard/bloc/dashboard_state.dart +++ b/lib/dashboard/bloc/dashboard_state.dart @@ -27,14 +27,14 @@ final class DashboardState extends Equatable { final DashboardStatus status; final DashboardSummary? summary; - final AppConfig? appConfig; + final RemoteConfig? appConfig; final List recentHeadlines; final String? errorMessage; DashboardState copyWith({ DashboardStatus? status, DashboardSummary? summary, - AppConfig? appConfig, + RemoteConfig? appConfig, List? recentHeadlines, String? errorMessage, }) { diff --git a/lib/dashboard/view/dashboard_page.dart b/lib/dashboard/view/dashboard_page.dart index 9215bf1..418b1f2 100644 --- a/lib/dashboard/view/dashboard_page.dart +++ b/lib/dashboard/view/dashboard_page.dart @@ -65,14 +65,14 @@ class _DashboardPageState extends State { final summaryCards = [ _SummaryCard( - icon: Icons.article_outlined, - title: l10n.totalHeadlines, - value: summary.headlineCount.toString(), + icon: Icons.category_outlined, + title: l10n.totalTopics, + value: summary.topicCount.toString(), ), _SummaryCard( icon: Icons.category_outlined, - title: l10n.totalCategories, - value: summary.categoryCount.toString(), + title: l10n.totalTopics, + value: summary.topicCount.toString(), ), _SummaryCard( icon: Icons.source_outlined, @@ -116,7 +116,7 @@ class _DashboardPageState extends State { Column( children: [ _SystemStatusCard( - status: appConfig.appOperationalStatus, + appStatus: appConfig.appStatus, ), const SizedBox(height: AppSpacing.lg), const _QuickActionsCard(), @@ -157,16 +157,16 @@ class _DashboardPageState extends State { /// A card to display the current operational status of the application. class _SystemStatusCard extends StatelessWidget { - const _SystemStatusCard({required this.status}); + const _SystemStatusCard({required this.appStatus}); - final RemoteAppStatus status; + final AppStatus appStatus; @override Widget build(BuildContext context) { final l10n = context.l10n; final theme = Theme.of(context); - final (icon, color, text) = _getStatusDetails(status, l10n, theme); + final (icon, color, text) = _getStatusDetails(appStatus, l10n, theme); return Card( child: Padding( @@ -194,29 +194,28 @@ class _SystemStatusCard extends StatelessWidget { /// Returns the appropriate icon, color, and text for a given status. (IconData, Color, String) _getStatusDetails( - RemoteAppStatus status, + AppStatus appStatus, AppLocalizations l10n, ThemeData theme, ) { - switch (status) { - case RemoteAppStatus.active: - return ( - Icons.check_circle_outline, - theme.colorScheme.primary, - l10n.appStatusActive, - ); - case RemoteAppStatus.maintenance: - return ( - Icons.warning_amber_outlined, - theme.colorScheme.tertiary, - l10n.appStatusMaintenance, - ); - case RemoteAppStatus.disabled: - return ( - Icons.cancel_outlined, - theme.colorScheme.error, - l10n.appStatusDisabled, - ); + if (appStatus.isUnderMaintenance) { + return ( + Icons.warning_amber_outlined, + theme.colorScheme.tertiary, + l10n.appStatusMaintenance, + ); + } else if (appStatus.isLatestVersionOnly) { + return ( + Icons.cancel_outlined, + theme.colorScheme.error, + l10n.appStatusDisabled, + ); + } else { + return ( + Icons.check_circle_outline, + theme.colorScheme.primary, + l10n.appStatusActive, + ); } } } @@ -246,8 +245,8 @@ class _QuickActionsCard extends StatelessWidget { const SizedBox(height: AppSpacing.sm), OutlinedButton.icon( icon: const Icon(Icons.create_new_folder_outlined), - label: Text(l10n.createCategory), - onPressed: () => context.goNamed(Routes.createCategoryName), + label: Text(l10n.createTopic), + onPressed: () => context.goNamed(Routes.createTopicName), ), const SizedBox(height: AppSpacing.sm), OutlinedButton.icon( diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8d32254..75ab7f3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -98,12 +98,6 @@ abstract class AppLocalizations { Locale('en'), ]; - /// The conventional newborn programmer greeting - /// - /// In en, this message translates to: - /// **'Hello World!'** - String get helloWorld; - /// Headline for the main authentication page /// /// In en, this message translates to: @@ -227,7 +221,7 @@ abstract class AppLocalizations { /// Description for the Content Management page /// /// In en, this message translates to: - /// **'Manage news headlines, categories, and sources for the Dashboard.'** + /// **'Manage news headlines, topics, and sources for the Dashboard.'** String get contentManagementPageDescription; /// Label for the headlines subpage @@ -236,11 +230,11 @@ abstract class AppLocalizations { /// **'Headlines'** String get headlines; - /// Label for the categories subpage + /// Label for the topics subpage /// /// In en, this message translates to: - /// **'Categories'** - String get categories; + /// **'Topics'** + String get topics; /// Label for the sources subpage /// @@ -383,7 +377,7 @@ abstract class AppLocalizations { /// Description for User Content Limits section /// /// In en, this message translates to: - /// **'These settings define the maximum number of countries, news sources, categories, and saved headlines a user can follow or save. Limits vary by user type (Guest, Standard, Premium) and directly impact what content users can curate.'** + /// **'These settings define the maximum number of countries, news sources, topics, and saved headlines a user can follow or save. Limits vary by user type (Guest, Standard, Premium) and directly impact what content users can curate.'** String get userContentLimitsDescription; /// Tab title for Guest user role @@ -413,7 +407,7 @@ abstract class AppLocalizations { /// Description for Guest Followed Items Limit /// /// In en, this message translates to: - /// **'Maximum number of countries, news sources, or categories a Guest user can follow (each type has its own limit).'** + /// **'Maximum number of countries, news sources, or topics a Guest user can follow (each type has its own limit).'** String get guestFollowedItemsLimitDescription; /// Label for Guest Saved Headlines Limit @@ -437,7 +431,7 @@ abstract class AppLocalizations { /// Description for Standard User Followed Items Limit /// /// In en, this message translates to: - /// **'Maximum number of countries, news sources, or categories a Standard user can follow (each type has its own limit).'** + /// **'Maximum number of countries, news sources, or topics a Standard user can follow (each type has its own limit).'** String get standardUserFollowedItemsLimitDescription; /// Label for Standard User Saved Headlines Limit @@ -461,7 +455,7 @@ abstract class AppLocalizations { /// Description for Premium Followed Items Limit /// /// In en, this message translates to: - /// **'Maximum number of countries, news sources, or categories a Premium user can follow (each type has its own limit).'** + /// **'Maximum number of countries, news sources, or topics a Premium user can follow (each type has its own limit).'** String get premiumFollowedItemsLimitDescription; /// Label for Premium Saved Headlines Limit @@ -1016,6 +1010,18 @@ abstract class AppLocalizations { /// **'Title'** String get headlineTitle; + /// Label for the excerpt input field + /// + /// In en, this message translates to: + /// **'Excerpt'** + String get excerpt; + + /// Label for the country name dropdown field + /// + /// In en, this message translates to: + /// **'Country'** + String get countryName; + /// Column header for published date /// /// In en, this message translates to: @@ -1034,23 +1040,23 @@ abstract class AppLocalizations { /// **'Unknown'** String get unknown; - /// Headline for loading state of categories + /// Headline for loading state of topics /// /// In en, this message translates to: - /// **'Loading Categories'** - String get loadingCategories; + /// **'Loading Topics'** + String get loadingTopics; - /// Message when no categories are found + /// Message when no topics are found /// /// In en, this message translates to: - /// **'No categories found.'** - String get noCategoriesFound; + /// **'No topics found.'** + String get noTopicsFound; - /// Label for the category name field in forms and tables. + /// Label for the topic name field in forms and tables. /// /// In en, this message translates to: - /// **'Category Name'** - String get categoryName; + /// **'Topic Name'** + String get topicName; /// Column header for description /// @@ -1094,11 +1100,11 @@ abstract class AppLocalizations { /// **'Language'** String get language; - /// Title for the Edit Category page + /// Title for the Edit Topic page /// /// In en, this message translates to: - /// **'Edit Category'** - String get editCategory; + /// **'Edit Topic'** + String get editTopic; /// Tooltip for the save changes button /// @@ -1106,11 +1112,11 @@ abstract class AppLocalizations { /// **'Save Changes'** String get saveChanges; - /// Message displayed while loading category data + /// Message displayed while loading topic data /// /// In en, this message translates to: - /// **'Loading Category'** - String get loadingCategory; + /// **'Loading Topic'** + String get loadingTopic; /// Label for the icon URL input field /// @@ -1118,29 +1124,29 @@ abstract class AppLocalizations { /// **'Icon URL'** String get iconUrl; - /// Message displayed when a category is updated successfully + /// Message displayed when a topic is updated successfully /// /// In en, this message translates to: - /// **'Category updated successfully.'** - String get categoryUpdatedSuccessfully; + /// **'Topic updated successfully.'** + String get topicUpdatedSuccessfully; - /// Error message when updating a category fails because the original data wasn't loaded + /// Error message when updating a topic fails because the original data wasn't loaded /// /// In en, this message translates to: - /// **'Cannot update: Original category data not loaded.'** - String get cannotUpdateCategoryError; + /// **'Cannot update: Original topic data not loaded.'** + String get cannotUpdateTopicError; - /// Title for the Create Category page + /// Title for the Create Topic page /// /// In en, this message translates to: - /// **'Create Category'** - String get createCategory; + /// **'Create Topic'** + String get createTopic; - /// Message displayed when a category is created successfully + /// Message displayed when a topic is created successfully /// /// In en, this message translates to: - /// **'Category created successfully.'** - String get categoryCreatedSuccessfully; + /// **'Topic created successfully.'** + String get topicCreatedSuccessfully; /// Title for the Edit Source page /// @@ -1334,11 +1340,11 @@ abstract class AppLocalizations { /// **'Total Headlines'** String get totalHeadlines; - /// Label for the total categories summary card on the dashboard + /// Label for the total topics summary card on the dashboard /// /// In en, this message translates to: - /// **'Total Categories'** - String get totalCategories; + /// **'Total Topics'** + String get totalTopics; /// Label for the total sources summary card on the dashboard /// @@ -1418,12 +1424,6 @@ abstract class AppLocalizations { /// **'Active'** String get appStatusActive; - /// Text for the 'Maintenance' app status - /// - /// In en, this message translates to: - /// **'Maintenance'** - String get appStatusMaintenance; - /// Text for the 'Disabled' app status /// /// In en, this message translates to: @@ -1441,6 +1441,66 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'For demo, use code: {code}'** String demoCodeHint(String code); + + /// Text for the 'Maintenance' app status + /// + /// In en, this message translates to: + /// **'Maintenance'** + String get appStatusMaintenance; + + /// Text for the 'Operational' app status + /// + /// In en, this message translates to: + /// **'Operational'** + String get appStatusOperational; + + /// Label for the 'is under maintenance' switch + /// + /// In en, this message translates to: + /// **'Under Maintenance'** + String get isUnderMaintenanceLabel; + + /// Description for the 'is under maintenance' switch + /// + /// In en, this message translates to: + /// **'Toggle to put the app in maintenance mode, preventing user access.'** + String get isUnderMaintenanceDescription; + + /// Label for the 'is latest version only' switch + /// + /// In en, this message translates to: + /// **'Force Latest Version Only'** + String get isLatestVersionOnlyLabel; + + /// Description for the 'is latest version only' switch + /// + /// In en, this message translates to: + /// **'If enabled, users must update to the latest app version to continue using the app.'** + String get isLatestVersionOnlyDescription; + + /// Label for iOS Update URL + /// + /// In en, this message translates to: + /// **'iOS Update URL'** + String get iosUpdateUrlLabel; + + /// Description for iOS Update URL + /// + /// In en, this message translates to: + /// **'URL for iOS app updates.'** + String get iosUpdateUrlDescription; + + /// Label for Android Update URL + /// + /// In en, this message translates to: + /// **'Android Update URL'** + String get androidUpdateUrlLabel; + + /// Description for Android Update URL + /// + /// In en, this message translates to: + /// **'URL for Android app updates.'** + String get androidUpdateUrlDescription; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index fdcbfbc..6f82381 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -8,9 +8,6 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); - @override - String get helloWorld => 'مرحبا بالعالم!'; - @override String get authenticationPageHeadline => 'الوصول إلى لوحة التحكم'; @@ -82,13 +79,13 @@ class AppLocalizationsAr extends AppLocalizations { @override String get contentManagementPageDescription => - 'إدارة العناوين الإخبارية والفئات والمصادر للوحة القيادة.'; + 'إدارة العناوين الإخبارية والمواضيع والمصادر للوحة القيادة.'; @override String get headlines => 'العناوين الرئيسية'; @override - String get categories => 'الفئات'; + String get topics => 'المواضيع'; @override String get sources => 'المصادر'; @@ -167,7 +164,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get userContentLimitsDescription => - 'تحدد هذه الإعدادات الحد الأقصى لعدد البلدان ومصادر الأخبار والفئات والعناوين المحفوظة التي يمكن للمستخدم متابعتها أو حفظها. تختلف الحدود حسب نوع المستخدم (ضيف، عادي، مميز) وتؤثر بشكل مباشر على المحتوى الذي يمكن للمستخدمين تنسيقه.'; + 'تحدد هذه الإعدادات الحد الأقصى لعدد البلدان ومصادر الأخبار والمواضيع والعناوين المحفوظة التي يمكن للمستخدم متابعتها أو حفظها. تختلف الحدود حسب نوع المستخدم (ضيف، عادي، مميز) وتؤثر بشكل مباشر على المحتوى الذي يمكن للمستخدمين تنسيقه.'; @override String get guestUserTab => 'ضيف'; @@ -183,7 +180,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get guestFollowedItemsLimitDescription => - 'الحد الأقصى لعدد البلدان أو مصادر الأخبار أو الفئات التي يمكن للمستخدم الضيف متابعتها (لكل نوع حد خاص به).'; + 'الحد الأقصى لعدد البلدان أو مصادر الأخبار أو المواضيع التي يمكن للمستخدم الضيف متابعتها (لكل نوع حد خاص به).'; @override String get guestSavedHeadlinesLimitLabel => 'حد العناوين المحفوظة للضيف'; @@ -198,7 +195,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get standardUserFollowedItemsLimitDescription => - 'الحد الأقصى لعدد البلدان أو مصادر الأخبار أو الفئات التي يمكن للمستخدم العادي متابعتها (لكل نوع حد خاص به).'; + 'الحد الأقصى لعدد البلدان أو مصادر الأخبار أو المواضيع التي يمكن للمستخدم العادي متابعتها (لكل نوع حد خاص به).'; @override String get standardUserSavedHeadlinesLimitLabel => @@ -214,7 +211,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get premiumFollowedItemsLimitDescription => - 'الحد الأقصى لعدد البلدان أو مصادر الأخبار أو الفئات التي يمكن للمستخدم المميز متابعتها (لكل نوع حد خاص به).'; + 'الحد الأقصى لعدد البلدان أو مصادر الأخبار أو المواضيع التي يمكن للمستخدم المميز متابعتها (لكل نوع حد خاص به).'; @override String get premiumSavedHeadlinesLimitLabel => @@ -535,6 +532,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String get headlineTitle => 'العنوان'; + @override + String get excerpt => 'المقتطف'; + + @override + String get countryName => 'البلد'; + @override String get publishedAt => 'تاريخ النشر'; @@ -545,13 +548,13 @@ class AppLocalizationsAr extends AppLocalizations { String get unknown => 'غير معروف'; @override - String get loadingCategories => 'جاري تحميل الفئات'; + String get loadingTopics => 'جاري تحميل المواضيع'; @override - String get noCategoriesFound => 'لم يتم العثور على فئات.'; + String get noTopicsFound => 'لم يتم العثور على مواضيع.'; @override - String get categoryName => 'اسم الفئة'; + String get topicName => 'اسم الموضوع'; @override String get description => 'الوصف'; @@ -575,29 +578,29 @@ class AppLocalizationsAr extends AppLocalizations { String get language => 'اللغة'; @override - String get editCategory => 'تعديل الفئة'; + String get editTopic => 'تعديل الموضوع'; @override String get saveChanges => 'حفظ التغييرات'; @override - String get loadingCategory => 'جاري تحميل الفئة'; + String get loadingTopic => 'جاري تحميل الموضوع'; @override String get iconUrl => 'رابط الأيقونة'; @override - String get categoryUpdatedSuccessfully => 'تم تحديث الفئة بنجاح.'; + String get topicUpdatedSuccessfully => 'تم تحديث الموضوع بنجاح.'; @override - String get cannotUpdateCategoryError => - 'لا يمكن التحديث: لم يتم تحميل بيانات الفئة الأصلية.'; + String get cannotUpdateTopicError => + 'لا يمكن التحديث: لم يتم تحميل بيانات الموضوع الأصلية.'; @override - String get createCategory => 'إنشاء فئة'; + String get createTopic => 'إنشاء موضوع'; @override - String get categoryCreatedSuccessfully => 'تم إنشاء الفئة بنجاح.'; + String get topicCreatedSuccessfully => 'تم إنشاء الموضوع بنجاح.'; @override String get editSource => 'تعديل المصدر'; @@ -698,7 +701,7 @@ class AppLocalizationsAr extends AppLocalizations { String get totalHeadlines => 'إجمالي العناوين'; @override - String get totalCategories => 'إجمالي الفئات'; + String get totalTopics => 'إجمالي المواضيع'; @override String get totalSources => 'إجمالي المصادر'; @@ -739,9 +742,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get appStatusActive => 'نشط'; - @override - String get appStatusMaintenance => 'صيانة'; - @override String get appStatusDisabled => 'معطل'; @@ -754,4 +754,36 @@ class AppLocalizationsAr extends AppLocalizations { String demoCodeHint(String code) { return 'للعرض التجريبي، استخدم الرمز: $code'; } + + @override + String get appStatusMaintenance => 'صيانة'; + + @override + String get appStatusOperational => 'تشغيلي'; + + @override + String get isUnderMaintenanceLabel => 'تحت الصيانة'; + + @override + String get isUnderMaintenanceDescription => + 'تبديل لوضع التطبيق في وضع الصيانة، مما يمنع وصول المستخدمين.'; + + @override + String get isLatestVersionOnlyLabel => 'فرض أحدث إصدار فقط'; + + @override + String get isLatestVersionOnlyDescription => + 'إذا تم التمكين، يجب على المستخدمين التحديث إلى أحدث إصدار من التطبيق لمواصلة استخدامه.'; + + @override + String get iosUpdateUrlLabel => 'رابط تحديث iOS'; + + @override + String get iosUpdateUrlDescription => 'رابط تحديثات تطبيق iOS.'; + + @override + String get androidUpdateUrlLabel => 'رابط تحديث Android'; + + @override + String get androidUpdateUrlDescription => 'رابط تحديثات تطبيق Android.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2bf1ee6..16f9b59 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -8,9 +8,6 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); - @override - String get helloWorld => 'Hello World!'; - @override String get authenticationPageHeadline => 'Dashboard Access'; @@ -81,13 +78,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get contentManagementPageDescription => - 'Manage news headlines, categories, and sources for the Dashboard.'; + 'Manage news headlines, topics, and sources for the Dashboard.'; @override String get headlines => 'Headlines'; @override - String get categories => 'Categories'; + String get topics => 'Topics'; @override String get sources => 'Sources'; @@ -168,7 +165,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get userContentLimitsDescription => - 'These settings define the maximum number of countries, news sources, categories, and saved headlines a user can follow or save. Limits vary by user type (Guest, Standard, Premium) and directly impact what content users can curate.'; + 'These settings define the maximum number of countries, news sources, topics, and saved headlines a user can follow or save. Limits vary by user type (Guest, Standard, Premium) and directly impact what content users can curate.'; @override String get guestUserTab => 'Guest'; @@ -184,7 +181,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get guestFollowedItemsLimitDescription => - 'Maximum number of countries, news sources, or categories a Guest user can follow (each type has its own limit).'; + 'Maximum number of countries, news sources, or topics a Guest user can follow (each type has its own limit).'; @override String get guestSavedHeadlinesLimitLabel => 'Guest Saved Headlines Limit'; @@ -199,7 +196,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get standardUserFollowedItemsLimitDescription => - 'Maximum number of countries, news sources, or categories a Standard user can follow (each type has its own limit).'; + 'Maximum number of countries, news sources, or topics a Standard user can follow (each type has its own limit).'; @override String get standardUserSavedHeadlinesLimitLabel => @@ -214,7 +211,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get premiumFollowedItemsLimitDescription => - 'Maximum number of countries, news sources, or categories a Premium user can follow (each type has its own limit).'; + 'Maximum number of countries, news sources, or topics a Premium user can follow (each type has its own limit).'; @override String get premiumSavedHeadlinesLimitLabel => 'Premium Saved Headlines Limit'; @@ -533,6 +530,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get headlineTitle => 'Title'; + @override + String get excerpt => 'Excerpt'; + + @override + String get countryName => 'Country'; + @override String get publishedAt => 'Published At'; @@ -543,13 +546,13 @@ class AppLocalizationsEn extends AppLocalizations { String get unknown => 'Unknown'; @override - String get loadingCategories => 'Loading Categories'; + String get loadingTopics => 'Loading Topics'; @override - String get noCategoriesFound => 'No categories found.'; + String get noTopicsFound => 'No topics found.'; @override - String get categoryName => 'Category Name'; + String get topicName => 'Topic Name'; @override String get description => 'Description'; @@ -573,29 +576,29 @@ class AppLocalizationsEn extends AppLocalizations { String get language => 'Language'; @override - String get editCategory => 'Edit Category'; + String get editTopic => 'Edit Topic'; @override String get saveChanges => 'Save Changes'; @override - String get loadingCategory => 'Loading Category'; + String get loadingTopic => 'Loading Topic'; @override String get iconUrl => 'Icon URL'; @override - String get categoryUpdatedSuccessfully => 'Category updated successfully.'; + String get topicUpdatedSuccessfully => 'Topic updated successfully.'; @override - String get cannotUpdateCategoryError => - 'Cannot update: Original category data not loaded.'; + String get cannotUpdateTopicError => + 'Cannot update: Original topic data not loaded.'; @override - String get createCategory => 'Create Category'; + String get createTopic => 'Create Topic'; @override - String get categoryCreatedSuccessfully => 'Category created successfully.'; + String get topicCreatedSuccessfully => 'Topic created successfully.'; @override String get editSource => 'Edit Source'; @@ -696,7 +699,7 @@ class AppLocalizationsEn extends AppLocalizations { String get totalHeadlines => 'Total Headlines'; @override - String get totalCategories => 'Total Categories'; + String get totalTopics => 'Total Topics'; @override String get totalSources => 'Total Sources'; @@ -737,9 +740,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appStatusActive => 'Active'; - @override - String get appStatusMaintenance => 'Maintenance'; - @override String get appStatusDisabled => 'Disabled'; @@ -752,4 +752,36 @@ class AppLocalizationsEn extends AppLocalizations { String demoCodeHint(String code) { return 'For demo, use code: $code'; } + + @override + String get appStatusMaintenance => 'Maintenance'; + + @override + String get appStatusOperational => 'Operational'; + + @override + String get isUnderMaintenanceLabel => 'Under Maintenance'; + + @override + String get isUnderMaintenanceDescription => + 'Toggle to put the app in maintenance mode, preventing user access.'; + + @override + String get isLatestVersionOnlyLabel => 'Force Latest Version Only'; + + @override + String get isLatestVersionOnlyDescription => + 'If enabled, users must update to the latest app version to continue using the app.'; + + @override + String get iosUpdateUrlLabel => 'iOS Update URL'; + + @override + String get iosUpdateUrlDescription => 'URL for iOS app updates.'; + + @override + String get androidUpdateUrlLabel => 'Android Update URL'; + + @override + String get androidUpdateUrlDescription => 'URL for Android app updates.'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 66f7935..027f35a 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1,8 +1,4 @@ { - "helloWorld": "مرحبا بالعالم!", - "@helloWorld": { - "description": "التحية التقليدية للمبرمج حديث الولادة" - }, "authenticationPageHeadline": "الوصول إلى لوحة التحكم", "@authenticationPageHeadline": { "description": "عنوان صفحة المصادقة الرئيسية" @@ -93,7 +89,7 @@ "@contentManagement": { "description": "تسمية عنصر التنقل لإدارة المحتوى" }, - "contentManagementPageDescription": "إدارة العناوين الإخبارية والفئات والمصادر للوحة القيادة.", + "contentManagementPageDescription": "إدارة العناوين الإخبارية والمواضيع والمصادر للوحة القيادة.", "@contentManagementPageDescription": { "description": "وصف صفحة إدارة المحتوى" }, @@ -101,9 +97,9 @@ "@headlines": { "description": "تسمية الصفحة الفرعية للعناوين الرئيسية" }, - "categories": "الفئات", - "@categories": { - "description": "تسمية الصفحة الفرعية للفئات" + "topics": "المواضيع", + "@topics": { + "description": "تسمية الصفحة الفرعية للمواضيع" }, "sources": "المصادر", "@sources": { @@ -202,7 +198,7 @@ "@confirmSaveButton": { "description": "تسمية زر تأكيد الحفظ في مربع الحوار" }, - "userContentLimitsDescription": "تحدد هذه الإعدادات الحد الأقصى لعدد البلدان ومصادر الأخبار والفئات والعناوين المحفوظة التي يمكن للمستخدم متابعتها أو حفظها. تختلف الحدود حسب نوع المستخدم (ضيف، عادي، مميز) وتؤثر بشكل مباشر على المحتوى الذي يمكن للمستخدمين تنسيقه.", + "userContentLimitsDescription": "تحدد هذه الإعدادات الحد الأقصى لعدد البلدان ومصادر الأخبار والمواضيع والعناوين المحفوظة التي يمكن للمستخدم متابعتها أو حفظها. تختلف الحدود حسب نوع المستخدم (ضيف، عادي، مميز) وتؤثر بشكل مباشر على المحتوى الذي يمكن للمستخدمين تنسيقه.", "@userContentLimitsDescription": { "description": "وصف قسم حدود محتوى المستخدم" }, @@ -222,7 +218,7 @@ "@guestFollowedItemsLimitLabel": { "description": "تسمية حد العناصر المتابعة للضيف" }, - "guestFollowedItemsLimitDescription": "الحد الأقصى لعدد البلدان أو مصادر الأخبار أو الفئات التي يمكن للمستخدم الضيف متابعتها (لكل نوع حد خاص به).", + "guestFollowedItemsLimitDescription": "الحد الأقصى لعدد البلدان أو مصادر الأخبار أو المواضيع التي يمكن للمستخدم الضيف متابعتها (لكل نوع حد خاص به).", "@guestFollowedItemsLimitDescription": { "description": "وصف حد العناصر المتابعة للضيف" }, @@ -238,7 +234,7 @@ "@standardUserFollowedItemsLimitLabel": { "description": "تسمية حد العناصر المتابعة للمستخدم العادي" }, - "standardUserFollowedItemsLimitDescription": "الحد الأقصى لعدد البلدان أو مصادر الأخبار أو الفئات التي يمكن للمستخدم العادي متابعتها (لكل نوع حد خاص به).", + "standardUserFollowedItemsLimitDescription": "الحد الأقصى لعدد البلدان أو مصادر الأخبار أو المواضيع التي يمكن للمستخدم العادي متابعتها (لكل نوع حد خاص به).", "@standardUserFollowedItemsLimitDescription": { "description": "وصف حد العناصر المتابعة للمستخدم العادي" }, @@ -254,7 +250,7 @@ "@premiumFollowedItemsLimitLabel": { "description": "تسمية حد العناصر المتابعة للمستخدم المميز" }, - "premiumFollowedItemsLimitDescription": "الحد الأقصى لعدد البلدان أو مصادر الأخبار أو الفئات التي يمكن للمستخدم المميز متابعتها (لكل نوع حد خاص به).", + "premiumFollowedItemsLimitDescription": "الحد الأقصى لعدد البلدان أو مصادر الأخبار أو المواضيع التي يمكن للمستخدم المميز متابعتها (لكل نوع حد خاص به).", "@premiumFollowedItemsLimitDescription": { "description": "وصف حد العناصر المتابعة للمستخدم المميز" }, @@ -636,6 +632,14 @@ "@headlineTitle": { "description": "رأس العمود لعنوان الخبر" }, + "excerpt": "المقتطف", + "@excerpt": { + "description": "تسمية حقل إدخال المقتطف" + }, + "countryName": "البلد", + "@countryName": { + "description": "تسمية حقل القائمة المنسدلة لاسم البلد" + }, "publishedAt": "تاريخ النشر", "@publishedAt": { "description": "رأس العمود لتاريخ النشر" @@ -648,17 +652,17 @@ "@unknown": { "description": "نص احتياطي للقيم غير المعروفة" }, - "loadingCategories": "جاري تحميل الفئات", - "@loadingCategories": { - "description": "عنوان حالة تحميل الفئات" + "loadingTopics": "جاري تحميل المواضيع", + "@loadingTopics": { + "description": "عنوان حالة تحميل المواضيع" }, - "noCategoriesFound": "لم يتم العثور على فئات.", - "@noCategoriesFound": { - "description": "رسالة عند عدم العثور على فئات" + "noTopicsFound": "لم يتم العثور على مواضيع.", + "@noTopicsFound": { + "description": "رسالة عند عدم العثور على مواضيع" }, - "categoryName": "اسم الفئة", - "@categoryName": { - "description": "تسمية حقل اسم الفئة في النماذج والجداول." + "topicName": "اسم الموضوع", + "@topicName": { + "description": "تسمية حقل اسم الموضوع في النماذج والجداول." }, "description": "الوصف", "@description": { @@ -688,37 +692,37 @@ "@language": { "description": "رأس العمود للغة" }, - "editCategory": "تعديل الفئة", - "@editCategory": { - "description": "عنوان صفحة تعديل الفئة" + "editTopic": "تعديل الموضوع", + "@editTopic": { + "description": "عنوان صفحة تعديل الموضوع" }, "saveChanges": "حفظ التغييرات", "@saveChanges": { "description": "تلميح لزر حفظ التغييرات" }, - "loadingCategory": "جاري تحميل الفئة", - "@loadingCategory": { - "description": "رسالة تُعرض أثناء تحميل بيانات الفئة" + "loadingTopic": "جاري تحميل الموضوع", + "@loadingTopic": { + "description": "رسالة تُعرض أثناء تحميل بيانات الموضوع" }, "iconUrl": "رابط الأيقونة", "@iconUrl": { "description": "تسمية حقل إدخال رابط الأيقونة" }, - "categoryUpdatedSuccessfully": "تم تحديث الفئة بنجاح.", - "@categoryUpdatedSuccessfully": { - "description": "رسالة تُعرض عند تحديث الفئة بنجاح" + "topicUpdatedSuccessfully": "تم تحديث الموضوع بنجاح.", + "@topicUpdatedSuccessfully": { + "description": "رسالة تُعرض عند تحديث الموضوع بنجاح" }, - "cannotUpdateCategoryError": "لا يمكن التحديث: لم يتم تحميل بيانات الفئة الأصلية.", - "@cannotUpdateCategoryError": { - "description": "رسالة خطأ عند فشل تحديث الفئة بسبب عدم تحميل البيانات الأصلية" + "cannotUpdateTopicError": "لا يمكن التحديث: لم يتم تحميل بيانات الموضوع الأصلية.", + "@cannotUpdateTopicError": { + "description": "رسالة خطأ عند فشل تحديث الموضوع بسبب عدم تحميل البيانات الأصلية" }, - "createCategory": "إنشاء فئة", - "@createCategory": { - "description": "عنوان صفحة إنشاء فئة" + "createTopic": "إنشاء موضوع", + "@createTopic": { + "description": "عنوان صفحة إنشاء موضوع" }, - "categoryCreatedSuccessfully": "تم إنشاء الفئة بنجاح.", - "@categoryCreatedSuccessfully": { - "description": "رسالة تُعرض عند إنشاء الفئة بنجاح" + "topicCreatedSuccessfully": "تم إنشاء الموضوع بنجاح.", + "@topicCreatedSuccessfully": { + "description": "رسالة تُعرض عند إنشاء الموضوع بنجاح" }, "editSource": "تعديل المصدر", "@editSource": { @@ -838,9 +842,9 @@ "@totalHeadlines": { "description": "تسمية بطاقة ملخص إجمالي العناوين في لوحة القيادة" }, - "totalCategories": "إجمالي الفئات", - "@totalCategories": { - "description": "تسمية بطاقة ملخص إجمالي الفئات في لوحة القيادة" + "totalTopics": "إجمالي المواضيع", + "@totalTopics": { + "description": "تسمية بطاقة ملخص إجمالي المواضيع في لوحة القيادة" }, "totalSources": "إجمالي المصادر", "@totalSources": { @@ -894,10 +898,6 @@ "@appStatusActive": { "description": "نص حالة التطبيق 'نشط'" }, - "appStatusMaintenance": "صيانة", - "@appStatusMaintenance": { - "description": "نص حالة التطبيق 'صيانة'" - }, "appStatusDisabled": "معطل", "@appStatusDisabled": { "description": "نص حالة التطبيق 'معطل'" @@ -921,5 +921,45 @@ "example": "123456" } } + }, + "appStatusMaintenance": "صيانة", + "@appStatusMaintenance": { + "description": "نص حالة التطبيق 'صيانة'" + }, + "appStatusOperational": "تشغيلي", + "@appStatusOperational": { + "description": "نص حالة التطبيق 'تشغيلي'" + }, + "isUnderMaintenanceLabel": "تحت الصيانة", + "@isUnderMaintenanceLabel": { + "description": "تسمية مفتاح 'تحت الصيانة'" + }, + "isUnderMaintenanceDescription": "تبديل لوضع التطبيق في وضع الصيانة، مما يمنع وصول المستخدمين.", + "@isUnderMaintenanceDescription": { + "description": "وصف مفتاح 'تحت الصيانة'" + }, + "isLatestVersionOnlyLabel": "فرض أحدث إصدار فقط", + "@isLatestVersionOnlyLabel": { + "description": "تسمية مفتاح 'فرض أحدث إصدار فقط'" + }, + "isLatestVersionOnlyDescription": "إذا تم التمكين، يجب على المستخدمين التحديث إلى أحدث إصدار من التطبيق لمواصلة استخدامه.", + "@isLatestVersionOnlyDescription": { + "description": "وصف مفتاح 'فرض أحدث إصدار فقط'" + }, + "iosUpdateUrlLabel": "رابط تحديث iOS", + "@iosUpdateUrlLabel": { + "description": "تسمية رابط تحديث iOS" + }, + "iosUpdateUrlDescription": "رابط تحديثات تطبيق iOS.", + "@iosUpdateUrlDescription": { + "description": "وصف رابط تحديث iOS" + }, + "androidUpdateUrlLabel": "رابط تحديث Android", + "@androidUpdateUrlLabel": { + "description": "تسمية رابط تحديث Android" + }, + "androidUpdateUrlDescription": "رابط تحديثات تطبيق Android.", + "@androidUpdateUrlDescription": { + "description": "وصف رابط تحديث Android" } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index d423deb..5e72a00 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,8 +1,4 @@ { - "helloWorld": "Hello World!", - "@helloWorld": { - "description": "The conventional newborn programmer greeting" - }, "authenticationPageHeadline": "Dashboard Access", "@authenticationPageHeadline": { "description": "Headline for the main authentication page" @@ -93,7 +89,7 @@ "@contentManagement": { "description": "Label for the content management navigation item" }, - "contentManagementPageDescription": "Manage news headlines, categories, and sources for the Dashboard.", + "contentManagementPageDescription": "Manage news headlines, topics, and sources for the Dashboard.", "@contentManagementPageDescription": { "description": "Description for the Content Management page" }, @@ -101,9 +97,9 @@ "@headlines": { "description": "Label for the headlines subpage" }, - "categories": "Categories", - "@categories": { - "description": "Label for the categories subpage" + "topics": "Topics", + "@topics": { + "description": "Label for the topics subpage" }, "sources": "Sources", "@sources": { @@ -202,7 +198,7 @@ "@confirmSaveButton": { "description": "Confirm save button label in dialog" }, - "userContentLimitsDescription": "These settings define the maximum number of countries, news sources, categories, and saved headlines a user can follow or save. Limits vary by user type (Guest, Standard, Premium) and directly impact what content users can curate.", + "userContentLimitsDescription": "These settings define the maximum number of countries, news sources, topics, and saved headlines a user can follow or save. Limits vary by user type (Guest, Standard, Premium) and directly impact what content users can curate.", "@userContentLimitsDescription": { "description": "Description for User Content Limits section" }, @@ -222,7 +218,7 @@ "@guestFollowedItemsLimitLabel": { "description": "Label for Guest Followed Items Limit" }, - "guestFollowedItemsLimitDescription": "Maximum number of countries, news sources, or categories a Guest user can follow (each type has its own limit).", + "guestFollowedItemsLimitDescription": "Maximum number of countries, news sources, or topics a Guest user can follow (each type has its own limit).", "@guestFollowedItemsLimitDescription": { "description": "Description for Guest Followed Items Limit" }, @@ -238,7 +234,7 @@ "@standardUserFollowedItemsLimitLabel": { "description": "Label for Standard User Followed Items Limit" }, - "standardUserFollowedItemsLimitDescription": "Maximum number of countries, news sources, or categories a Standard user can follow (each type has its own limit).", + "standardUserFollowedItemsLimitDescription": "Maximum number of countries, news sources, or topics a Standard user can follow (each type has its own limit).", "@standardUserFollowedItemsLimitDescription": { "description": "Description for Standard User Followed Items Limit" }, @@ -254,7 +250,7 @@ "@premiumFollowedItemsLimitLabel": { "description": "Label for Premium Followed Items Limit" }, - "premiumFollowedItemsLimitDescription": "Maximum number of countries, news sources, or categories a Premium user can follow (each type has its own limit).", + "premiumFollowedItemsLimitDescription": "Maximum number of countries, news sources, or topics a Premium user can follow (each type has its own limit).", "@premiumFollowedItemsLimitDescription": { "description": "Description for Premium Followed Items Limit" }, @@ -636,6 +632,14 @@ "@headlineTitle": { "description": "Column header for headline title" }, + "excerpt": "Excerpt", + "@excerpt": { + "description": "Label for the excerpt input field" + }, + "countryName": "Country", + "@countryName": { + "description": "Label for the country name dropdown field" + }, "publishedAt": "Published At", "@publishedAt": { "description": "Column header for published date" @@ -648,17 +652,17 @@ "@unknown": { "description": "Fallback text for unknown values" }, - "loadingCategories": "Loading Categories", - "@loadingCategories": { - "description": "Headline for loading state of categories" + "loadingTopics": "Loading Topics", + "@loadingTopics": { + "description": "Headline for loading state of topics" }, - "noCategoriesFound": "No categories found.", - "@noCategoriesFound": { - "description": "Message when no categories are found" + "noTopicsFound": "No topics found.", + "@noTopicsFound": { + "description": "Message when no topics are found" }, - "categoryName": "Category Name", - "@categoryName": { - "description": "Label for the category name field in forms and tables." + "topicName": "Topic Name", + "@topicName": { + "description": "Label for the topic name field in forms and tables." }, "description": "Description", "@description": { @@ -688,37 +692,37 @@ "@language": { "description": "Column header for language" }, - "editCategory": "Edit Category", - "@editCategory": { - "description": "Title for the Edit Category page" + "editTopic": "Edit Topic", + "@editTopic": { + "description": "Title for the Edit Topic page" }, "saveChanges": "Save Changes", "@saveChanges": { "description": "Tooltip for the save changes button" }, - "loadingCategory": "Loading Category", - "@loadingCategory": { - "description": "Message displayed while loading category data" + "loadingTopic": "Loading Topic", + "@loadingTopic": { + "description": "Message displayed while loading topic data" }, "iconUrl": "Icon URL", "@iconUrl": { "description": "Label for the icon URL input field" }, - "categoryUpdatedSuccessfully": "Category updated successfully.", - "@categoryUpdatedSuccessfully": { - "description": "Message displayed when a category is updated successfully" + "topicUpdatedSuccessfully": "Topic updated successfully.", + "@topicUpdatedSuccessfully": { + "description": "Message displayed when a topic is updated successfully" }, - "cannotUpdateCategoryError": "Cannot update: Original category data not loaded.", - "@cannotUpdateCategoryError": { - "description": "Error message when updating a category fails because the original data wasn't loaded" + "cannotUpdateTopicError": "Cannot update: Original topic data not loaded.", + "@cannotUpdateTopicError": { + "description": "Error message when updating a topic fails because the original data wasn't loaded" }, - "createCategory": "Create Category", - "@createCategory": { - "description": "Title for the Create Category page" + "createTopic": "Create Topic", + "@createTopic": { + "description": "Title for the Create Topic page" }, - "categoryCreatedSuccessfully": "Category created successfully.", - "@categoryCreatedSuccessfully": { - "description": "Message displayed when a category is created successfully" + "topicCreatedSuccessfully": "Topic created successfully.", + "@topicCreatedSuccessfully": { + "description": "Message displayed when a topic is created successfully" }, "editSource": "Edit Source", "@editSource": { @@ -838,9 +842,9 @@ "@totalHeadlines": { "description": "Label for the total headlines summary card on the dashboard" }, - "totalCategories": "Total Categories", - "@totalCategories": { - "description": "Label for the total categories summary card on the dashboard" + "totalTopics": "Total Topics", + "@totalTopics": { + "description": "Label for the total topics summary card on the dashboard" }, "totalSources": "Total Sources", "@totalSources": { @@ -894,10 +898,6 @@ "@appStatusActive": { "description": "Text for the 'Active' app status" }, - "appStatusMaintenance": "Maintenance", - "@appStatusMaintenance": { - "description": "Text for the 'Maintenance' app status" - }, "appStatusDisabled": "Disabled", "@appStatusDisabled": { "description": "Text for the 'Disabled' app status" @@ -921,6 +921,45 @@ "example": "123456" } } + }, + "appStatusMaintenance": "Maintenance", + "@appStatusMaintenance": { + "description": "Text for the 'Maintenance' app status" + }, + "appStatusOperational": "Operational", + "@appStatusOperational": { + "description": "Text for the 'Operational' app status" + }, + "isUnderMaintenanceLabel": "Under Maintenance", + "@isUnderMaintenanceLabel": { + "description": "Label for the 'is under maintenance' switch" + }, + "isUnderMaintenanceDescription": "Toggle to put the app in maintenance mode, preventing user access.", + "@isUnderMaintenanceDescription": { + "description": "Description for the 'is under maintenance' switch" + }, + "isLatestVersionOnlyLabel": "Force Latest Version Only", + "@isLatestVersionOnlyLabel": { + "description": "Label for the 'is latest version only' switch" + }, + "isLatestVersionOnlyDescription": "If enabled, users must update to the latest app version to continue using the app.", + "@isLatestVersionOnlyDescription": { + "description": "Description for the 'is latest version only' switch" + }, + "iosUpdateUrlLabel": "iOS Update URL", + "@iosUpdateUrlLabel": { + "description": "Label for iOS Update URL" + }, + "iosUpdateUrlDescription": "URL for iOS app updates.", + "@iosUpdateUrlDescription": { + "description": "Description for iOS Update URL" + }, + "androidUpdateUrlLabel": "Android Update URL", + "@androidUpdateUrlLabel": { + "description": "Label for Android Update URL" + }, + "androidUpdateUrlDescription": "URL for Android app updates.", + "@androidUpdateUrlDescription": { + "description": "Description for Android Update URL" } - -} \ No newline at end of file +} diff --git a/lib/router/router.dart b/lib/router/router.dart index 1cb6b38..67bc9d1 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -11,10 +11,10 @@ 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/content_management/view/content_management_page.dart'; -import 'package:ht_dashboard/content_management/view/create_category_page.dart'; +import 'package:ht_dashboard/content_management/view/create_topic_page.dart'; import 'package:ht_dashboard/content_management/view/create_headline_page.dart'; import 'package:ht_dashboard/content_management/view/create_source_page.dart'; -import 'package:ht_dashboard/content_management/view/edit_category_page.dart'; +import 'package:ht_dashboard/content_management/view/edit_topic_page.dart'; import 'package:ht_dashboard/content_management/view/edit_headline_page.dart'; import 'package:ht_dashboard/content_management/view/edit_source_page.dart'; import 'package:ht_dashboard/dashboard/view/dashboard_page.dart'; @@ -170,16 +170,16 @@ GoRouter createRouter({ }, ), GoRoute( - path: Routes.createCategory, - name: Routes.createCategoryName, - builder: (context, state) => const CreateCategoryPage(), + path: Routes.createTopic, + name: Routes.createTopicName, + builder: (context, state) => const CreateTopicPage(), ), GoRoute( - path: Routes.editCategory, - name: Routes.editCategoryName, + path: Routes.editTopic, + name: Routes.editTopicName, builder: (context, state) { final id = state.pathParameters['id']!; - return EditCategoryPage(categoryId: id); + return EditTopicPage(topicId: id); }, ), GoRoute( diff --git a/lib/router/routes.dart b/lib/router/routes.dart index 0769631..88346a0 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -56,17 +56,17 @@ abstract final class Routes { /// The name for the edit headline page route. static const String editHeadlineName = 'editHeadline'; - /// The path for creating a new category. - static const String createCategory = 'create-category'; + /// The path for creating a new topic. + static const String createTopic = 'create-topic'; - /// The name for the create category page route. - static const String createCategoryName = 'createCategory'; + /// The name for the create topic page route. + static const String createTopicName = 'createTopic'; - /// The path for editing an existing category. - static const String editCategory = 'edit-category/:id'; + /// The path for editing an existing topic. + static const String editTopic = 'edit-topic/:id'; - /// The name for the edit category page route. - static const String editCategoryName = 'editCategory'; + /// The name for the edit topic page route. + static const String editTopicName = 'editTopic'; /// The path for creating a new source. static const String createSource = 'create-source'; diff --git a/lib/settings/bloc/settings_bloc.dart b/lib/settings/bloc/settings_bloc.dart index 973761d..d6222a1 100644 --- a/lib/settings/bloc/settings_bloc.dart +++ b/lib/settings/bloc/settings_bloc.dart @@ -35,7 +35,23 @@ class SettingsBloc extends Bloc { ); emit(SettingsLoadSuccess(userAppSettings: userAppSettings)); } on NotFoundException { - final defaultSettings = UserAppSettings(id: event.userId!); + final defaultSettings = UserAppSettings( + id: event.userId!, + displaySettings: const DisplaySettings( + baseTheme: AppBaseTheme.system, + accentTheme: AppAccentTheme.defaultBlue, + fontFamily: 'SystemDefault', + textScaleFactor: AppTextScaleFactor.medium, + fontWeight: AppFontWeight.regular, + ), + language: 'en', + feedPreferences: const FeedDisplayPreferences( + headlineDensity: HeadlineDensity.standard, + headlineImageStyle: HeadlineImageStyle.largeThumbnail, + showSourceInHeadlineFeed: true, + showPublishDateInHeadlineFeed: true, + ), + ); await _userAppSettingsRepository.create(item: defaultSettings); emit(SettingsLoadSuccess(userAppSettings: defaultSettings)); } on HtHttpException catch (e) { diff --git a/pubspec.lock b/pubspec.lock index 1024b5c..6e85eec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -205,7 +205,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: f89241dfd482d2a72b1168f979597a34b1004df5 + resolved-ref: a2fc2a651494831a461fff96807141bdba9cc28b url: "https://github.com/headlines-toolkit/ht-auth-api.git" source: git version: "0.0.0" @@ -223,7 +223,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "721a028b926a5a8af2b5176de039cd6394a21724" + resolved-ref: ea9ad0361b1e0beab4690959889b7056091d29dc url: "https://github.com/headlines-toolkit/ht-auth-inmemory" source: git version: "0.0.0" @@ -241,7 +241,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: b3073b812b4d5216f0ce5658be1be52e193083bb + resolved-ref: e6decb1f81ca233199f271539ddbf04bb9d63984 url: "https://github.com/headlines-toolkit/ht-data-api.git" source: git version: "0.0.0" @@ -250,7 +250,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "0077622bbd5c8886a4d7bfdf65540e6965564ad1" + resolved-ref: e566ee6eae5261c00d0987972efc77a37a0c4c3a url: "https://github.com/headlines-toolkit/ht-data-client.git" source: git version: "0.0.0" @@ -259,7 +259,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "948d8a237c9465b8fd9d3ab78bcea10841dc9a90" + resolved-ref: abef81e5294d70ace82d3e87f1efc94fca6a8445 url: "https://github.com/headlines-toolkit/ht-data-inmemory.git" source: git version: "0.0.0" @@ -268,7 +268,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "434c7a02cc85b7243a9a2f1bd662557ee19c3479" + resolved-ref: f19fe64c67a2febdef853b15f6df9c63240ad48e url: "https://github.com/headlines-toolkit/ht-data-repository.git" source: git version: "0.0.0" @@ -277,7 +277,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "648e8549d7ceb2ffb293fa66bdd02f5e0ca8def6" + resolved-ref: "0b56d92624769ca3175d5ce2c7da27ab29514f8a" url: "https://github.com/headlines-toolkit/ht-http-client.git" source: git version: "0.0.0" @@ -304,7 +304,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: "30aff4d0e2661ff79f2b84070af5f7982d88ba66" + resolved-ref: "83d73bdbc965b75425db346da8802be414b9ec0c" url: "https://github.com/headlines-toolkit/ht-shared.git" source: git version: "0.0.0" @@ -341,7 +341,7 @@ packages: source: hosted version: "4.9.0" logging: - dependency: transitive + dependency: "direct main" description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 @@ -570,7 +570,7 @@ packages: source: hosted version: "1.4.0" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff diff --git a/pubspec.yaml b/pubspec.yaml index 8534f3d..f47610d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,7 +58,9 @@ dependencies: git: url: https://github.com/headlines-toolkit/ht-shared.git intl: ^0.20.2 + logging: ^1.3.0 timeago: ^3.7.1 + uuid: ^4.5.1 dev_dependencies: