From eed97c466a1e7928a214c02410367c9aa04fc2e6 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 28 Jun 2025 14:37:28 +0100 Subject: [PATCH 1/8] feat: Add app configuration bloc - Added AppConfigurationBloc - Injected AppConfigRepository --- lib/app/view/app.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 15ef992d..aa5ac083 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:ht_auth_repository/ht_auth_repository.dart'; import 'package:ht_dashboard/app/bloc/app_bloc.dart'; import 'package:ht_dashboard/app/config/app_environment.dart'; +import 'package:ht_dashboard/app_configuration/bloc/app_configuration_bloc.dart'; import 'package:ht_dashboard/authentication/bloc/authentication_bloc.dart'; import 'package:ht_dashboard/l10n/app_localizations.dart'; import 'package:ht_dashboard/router/router.dart'; @@ -81,6 +82,11 @@ class App extends StatelessWidget { authenticationRepository: context.read(), ), ), + BlocProvider( + create: (context) => AppConfigurationBloc( + appConfigRepository: context.read>(), + ), + ), ], child: _AppView( htAuthenticationRepository: _htAuthenticationRepository, From e8d9885be352e1f2633ebb62df27901b81b4c33c Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 29 Jun 2025 05:37:06 +0100 Subject: [PATCH 2/8] feat(config): Add original config and save success - Added original config property - Added showSaveSuccess property - Implemented copyWith logic --- .../bloc/app_configuration_state.dart | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/app_configuration/bloc/app_configuration_state.dart b/lib/app_configuration/bloc/app_configuration_state.dart index f91ce76d..467d95ec 100644 --- a/lib/app_configuration/bloc/app_configuration_state.dart +++ b/lib/app_configuration/bloc/app_configuration_state.dart @@ -23,8 +23,10 @@ class AppConfigurationState extends Equatable { const AppConfigurationState({ this.status = AppConfigurationStatus.initial, this.appConfig, + this.originalAppConfig, this.errorMessage, this.isDirty = false, + this.showSaveSuccess = false, }); /// The current status of the application configuration. @@ -33,27 +35,40 @@ class AppConfigurationState extends Equatable { /// The loaded or updated application configuration. final AppConfig? appConfig; + /// The original application configuration loaded from the backend. + final AppConfig? originalAppConfig; + /// An error message if an operation failed. final String? errorMessage; /// Indicates if there are unsaved changes to the configuration. final bool isDirty; + /// Indicates if a save operation was successful and a snackbar should be shown. + final bool showSaveSuccess; + /// Creates a copy of the current state with updated values. AppConfigurationState copyWith({ AppConfigurationStatus? status, AppConfig? appConfig, + AppConfig? originalAppConfig, String? errorMessage, bool? isDirty, bool clearErrorMessage = false, + bool? showSaveSuccess, + bool clearShowSaveSuccess = false, }) { return AppConfigurationState( status: status ?? this.status, appConfig: appConfig ?? this.appConfig, + originalAppConfig: originalAppConfig ?? this.originalAppConfig, errorMessage: clearErrorMessage ? null : errorMessage ?? this.errorMessage, isDirty: isDirty ?? this.isDirty, + showSaveSuccess: clearShowSaveSuccess + ? false + : showSaveSuccess ?? this.showSaveSuccess, ); } @@ -61,7 +76,9 @@ class AppConfigurationState extends Equatable { List get props => [ status, appConfig, + originalAppConfig, errorMessage, isDirty, + showSaveSuccess, ]; } From 7e3259ee9af0849cbbd080fb38a14d3e04885744 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 29 Jun 2025 05:37:12 +0100 Subject: [PATCH 3/8] feat(config): add discard config event - Added AppConfigurationDiscarded event - Handles discarding config changes --- lib/app_configuration/bloc/app_configuration_event.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/app_configuration/bloc/app_configuration_event.dart b/lib/app_configuration/bloc/app_configuration_event.dart index fdf8b1a0..478f21c4 100644 --- a/lib/app_configuration/bloc/app_configuration_event.dart +++ b/lib/app_configuration/bloc/app_configuration_event.dart @@ -33,6 +33,14 @@ class AppConfigurationUpdated extends AppConfigurationEvent { List get props => [appConfig]; } +/// {@template app_configuration_discarded} +/// Event to discard any unsaved changes to the application configuration. +/// {@endtemplate} +class AppConfigurationDiscarded extends AppConfigurationEvent { + /// {@macro app_configuration_discarded} + const AppConfigurationDiscarded(); +} + /// {@template app_configuration_field_changed} /// Event to notify that a field in the application configuration has changed. /// From 861f986dbb8ec584eeab56bd57363b515dcf8f10 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 29 Jun 2025 05:37:18 +0100 Subject: [PATCH 4/8] feat(config): add discard changes functionality - Added discard changes event - Reverts to original config - Clears error/success messages --- .../bloc/app_configuration_bloc.dart | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/app_configuration/bloc/app_configuration_bloc.dart b/lib/app_configuration/bloc/app_configuration_bloc.dart index 61d4c9ec..8085e182 100644 --- a/lib/app_configuration/bloc/app_configuration_bloc.dart +++ b/lib/app_configuration/bloc/app_configuration_bloc.dart @@ -11,10 +11,13 @@ class AppConfigurationBloc AppConfigurationBloc({ required HtDataRepository appConfigRepository, }) : _appConfigRepository = appConfigRepository, - super(const AppConfigurationState()) { + super( + const AppConfigurationState(), + ) { on(_onAppConfigurationLoaded); on(_onAppConfigurationUpdated); on(_onAppConfigurationFieldChanged); + on(_onAppConfigurationDiscarded); } final HtDataRepository _appConfigRepository; @@ -30,7 +33,10 @@ class AppConfigurationBloc state.copyWith( status: AppConfigurationStatus.success, appConfig: appConfig, + originalAppConfig: appConfig, // Store the original config isDirty: false, + clearShowSaveSuccess: + true, // Clear any previous success snackbar flag ), ); } on HtHttpException catch (e) { @@ -64,7 +70,9 @@ class AppConfigurationBloc state.copyWith( status: AppConfigurationStatus.success, appConfig: updatedConfig, + originalAppConfig: updatedConfig, // Update original config on save isDirty: false, + showSaveSuccess: true, // Set flag to show success snackbar ), ); } on HtHttpException catch (e) { @@ -93,6 +101,21 @@ class AppConfigurationBloc appConfig: event.appConfig, isDirty: true, clearErrorMessage: true, // Clear any previous error messages + clearShowSaveSuccess: true, // Clear success snackbar on field change + ), + ); + } + + void _onAppConfigurationDiscarded( + AppConfigurationDiscarded event, + Emitter emit, + ) { + emit( + state.copyWith( + appConfig: state.originalAppConfig, // Revert to original config + isDirty: false, + clearErrorMessage: true, // Clear any previous error messages + clearShowSaveSuccess: true, // Clear success snackbar ), ); } From 586942f749ca97f942ee44d529a1245bd5deb482 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 29 Jun 2025 05:37:41 +0100 Subject: [PATCH 5/8] feat: Improve app config UI with tabbed sections - Implemented tabbed config sections - Added forms for each user role - Improved UX for setting limits --- .../view/app_configuration_page.dart | 973 +++++++++++------- 1 file changed, 621 insertions(+), 352 deletions(-) diff --git a/lib/app_configuration/view/app_configuration_page.dart b/lib/app_configuration/view/app_configuration_page.dart index da2fab0e..9b5a539a 100644 --- a/lib/app_configuration/view/app_configuration_page.dart +++ b/lib/app_configuration/view/app_configuration_page.dart @@ -63,7 +63,7 @@ class _AppConfigurationPageState extends State body: BlocConsumer( listener: (context, state) { if (state.status == AppConfigurationStatus.success && - state.isDirty == false) { + state.showSaveSuccess) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( @@ -77,6 +77,12 @@ class _AppConfigurationPageState extends State backgroundColor: Theme.of(context).colorScheme.primary, ), ); + // Clear the showSaveSuccess flag after showing the snackbar + context.read().add( + const AppConfigurationFieldChanged( + appConfig: null, // No actual config change, just clear flag + ), + ); } else if (state.status == AppConfigurationStatus.failure) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() @@ -152,9 +158,9 @@ class _AppConfigurationPageState extends State OutlinedButton( onPressed: isDirty ? () { - // Discard changes: reload original config + // Discard changes: revert to original config context.read().add( - const AppConfigurationLoaded(), + const AppConfigurationDiscarded(), ); } : null, @@ -219,316 +225,165 @@ class _AppConfigurationPageState extends State BuildContext context, AppConfig appConfig, ) { - final userPreferenceLimits = appConfig.userPreferenceLimits; - return SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'User Preference Limits', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: AppSpacing.md), - Text( - 'These settings define the maximum number of items a user can follow or save, tiered by user role. Changes here directly impact user capabilities.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + return DefaultTabController( + length: 3, // Guest, Authenticated, Premium + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'User Preference Limits', + style: Theme.of(context).textTheme.headlineSmall, ), - ), - const SizedBox(height: AppSpacing.lg), - _buildIntField( - context, - label: 'Guest Followed Items Limit', - description: - 'Max countries, sources, or categories a Guest user can follow (each).', - value: userPreferenceLimits.guestFollowedItemsLimit, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - userPreferenceLimits: userPreferenceLimits.copyWith( - guestFollowedItemsLimit: value, - ), - ), - ), - ); - }, - ), - _buildIntField( - context, - label: 'Guest Saved Headlines Limit', - description: 'Max headlines a Guest user can save.', - value: userPreferenceLimits.guestSavedHeadlinesLimit, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - userPreferenceLimits: userPreferenceLimits.copyWith( - guestSavedHeadlinesLimit: value, - ), - ), - ), - ); - }, - ), - _buildIntField( - context, - label: 'Authenticated Followed Items Limit', - description: - 'Max countries, sources, or categories an Authenticated user can follow (each).', - value: userPreferenceLimits.authenticatedFollowedItemsLimit, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - userPreferenceLimits: userPreferenceLimits.copyWith( - authenticatedFollowedItemsLimit: value, - ), - ), - ), - ); - }, - ), - _buildIntField( - context, - label: 'Authenticated Saved Headlines Limit', - description: 'Max headlines an Authenticated user can save.', - value: userPreferenceLimits.authenticatedSavedHeadlinesLimit, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - userPreferenceLimits: userPreferenceLimits.copyWith( - authenticatedSavedHeadlinesLimit: value, - ), + const SizedBox(height: AppSpacing.md), + Text( + 'These settings define the maximum number of items a user can follow or save, tiered by user role. Changes here directly impact user capabilities.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: AppSpacing.lg), + TabBar( + tabs: const [ + Tab(text: 'Guest'), + Tab(text: 'Authenticated'), + Tab(text: 'Premium'), + ], + labelColor: Theme.of(context).colorScheme.primary, + unselectedLabelColor: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), + indicatorColor: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + height: 400, // Adjust height as needed + child: TabBarView( + children: [ + _UserPreferenceLimitsForm( + userRole: UserRole.guestUser, + appConfig: appConfig, + onConfigChanged: (newConfig) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: newConfig, + ), + ); + }, + buildIntField: _buildIntField, ), - ), - ); - }, - ), - _buildIntField( - context, - label: 'Premium Followed Items Limit', - description: - 'Max countries, sources, or categories a Premium user can follow (each).', - value: userPreferenceLimits.premiumFollowedItemsLimit, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - userPreferenceLimits: userPreferenceLimits.copyWith( - premiumFollowedItemsLimit: value, - ), + _UserPreferenceLimitsForm( + userRole: UserRole.standardUser, + appConfig: appConfig, + onConfigChanged: (newConfig) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: newConfig, + ), + ); + }, + buildIntField: _buildIntField, ), - ), - ); - }, - ), - _buildIntField( - context, - label: 'Premium Saved Headlines Limit', - description: 'Max headlines a Premium user can save.', - value: userPreferenceLimits.premiumSavedHeadlinesLimit, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - userPreferenceLimits: userPreferenceLimits.copyWith( - premiumSavedHeadlinesLimit: value, - ), + _UserPreferenceLimitsForm( + userRole: UserRole.premiumUser, + appConfig: appConfig, + onConfigChanged: (newConfig) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: newConfig, + ), + ); + }, + buildIntField: _buildIntField, ), - ), - ); - }, - ), - ], + ], + ), + ), + ], + ), ), ); } Widget _buildAdConfigTab(BuildContext context, AppConfig appConfig) { - final adConfig = appConfig.adConfig; - return SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Ad Configuration', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: AppSpacing.md), - Text( - 'These settings control how ads are injected and displayed in the application, tiered by user role. AdFrequency determines how often an ad can be injected, and AdPlacementInterval sets a minimum number of primary items before the first ad.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + return DefaultTabController( + length: 3, // Guest, Authenticated, Premium + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ad Configuration', + style: Theme.of(context).textTheme.headlineSmall, ), - ), - const SizedBox(height: AppSpacing.lg), - _buildIntField( - context, - label: 'Guest Ad Frequency', - description: - 'How often an ad can be injected for Guest users (e.g., 5 means after every 5 primary items).', - value: adConfig.guestAdFrequency, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - adConfig: adConfig.copyWith(guestAdFrequency: value), - ), - ), - ); - }, - ), - _buildIntField( - context, - label: 'Guest Ad Placement Interval', - description: - 'Minimum primary items before the first ad for Guest users.', - value: adConfig.guestAdPlacementInterval, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - adConfig: adConfig.copyWith( - guestAdPlacementInterval: value, - ), - ), - ), - ); - }, - ), - _buildIntField( - context, - label: 'Authenticated Ad Frequency', - description: - 'How often an ad can be injected for Authenticated users.', - value: adConfig.authenticatedAdFrequency, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - adConfig: adConfig.copyWith( - authenticatedAdFrequency: value, - ), - ), - ), - ); - }, - ), - _buildIntField( - context, - label: 'Authenticated Ad Placement Interval', - description: - 'Minimum primary items before the first ad for Authenticated users.', - value: adConfig.authenticatedAdPlacementInterval, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - adConfig: adConfig.copyWith( - authenticatedAdPlacementInterval: value, - ), - ), - ), - ); - }, - ), - _buildIntField( - context, - label: 'Premium Ad Frequency', - description: - 'How often an ad can be injected for Premium users (0 for no ads).', - value: adConfig.premiumAdFrequency, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - adConfig: adConfig.copyWith(premiumAdFrequency: value), - ), - ), - ); - }, - ), - _buildIntField( - context, - label: 'Premium Ad Placement Interval', - description: - 'Minimum primary items before the first ad for Premium users.', - value: adConfig.premiumAdPlacementInterval, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - adConfig: adConfig.copyWith( - premiumAdPlacementInterval: value, - ), - ), - ), - ); - }, - ), - _buildIntField( - context, - label: 'Guest Articles Before Interstitial Ads', - description: - 'Number of articles a Guest user reads before an interstitial ad is shown.', - value: adConfig.guestArticlesToReadBeforeShowingInterstitialAds, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - adConfig: adConfig.copyWith( - guestArticlesToReadBeforeShowingInterstitialAds: value, - ), + const SizedBox(height: AppSpacing.md), + Text( + 'These settings control how ads are injected and displayed in the application, tiered by user role. AdFrequency determines how often an ad can be injected, and AdPlacementInterval sets a minimum number of primary items before the first ad.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: AppSpacing.lg), + TabBar( + tabs: const [ + Tab(text: 'Guest'), + Tab(text: 'Authenticated'), + Tab(text: 'Premium'), + ], + labelColor: Theme.of(context).colorScheme.primary, + unselectedLabelColor: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), + indicatorColor: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + height: 600, // Adjust height as needed + child: TabBarView( + children: [ + _AdConfigForm( + userRole: UserRole.guestUser, + appConfig: appConfig, + onConfigChanged: (newConfig) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: newConfig, + ), + ); + }, + buildIntField: _buildIntField, ), - ), - ); - }, - ), - _buildIntField( - context, - label: 'Standard User Articles Before Interstitial Ads', - description: - 'Number of articles a Standard user reads before an interstitial ad is shown.', - value: - adConfig.standardUserArticlesToReadBeforeShowingInterstitialAds, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - adConfig: adConfig.copyWith( - standardUserArticlesToReadBeforeShowingInterstitialAds: - value, - ), + _AdConfigForm( + userRole: UserRole.standardUser, + appConfig: appConfig, + onConfigChanged: (newConfig) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: newConfig, + ), + ); + }, + buildIntField: _buildIntField, ), - ), - ); - }, - ), - _buildIntField( - context, - label: 'Premium User Articles Before Interstitial Ads', - description: - 'Number of articles a Premium user reads before an interstitial ad is shown.', - value: - adConfig.premiumUserArticlesToReadBeforeShowingInterstitialAds, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - adConfig: adConfig.copyWith( - premiumUserArticlesToReadBeforeShowingInterstitialAds: - value, - ), + _AdConfigForm( + userRole: UserRole.premiumUser, + appConfig: appConfig, + onConfigChanged: (newConfig) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: newConfig, + ), + ); + }, + buildIntField: _buildIntField, ), - ), - ); - }, - ), - ], + ], + ), + ), + ], + ), ), ); } @@ -537,61 +392,70 @@ class _AppConfigurationPageState extends State BuildContext context, AppConfig appConfig, ) { - final accountActionConfig = appConfig.accountActionConfig; - return SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Account Action Configuration', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: AppSpacing.md), - Text( - 'These settings control the display frequency of in-feed account actions (e.g., link account, upgrade prompts), tiered by user role.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + return DefaultTabController( + length: 2, // Guest, Standard User + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Account Action Configuration', + style: Theme.of(context).textTheme.headlineSmall, ), - ), - const SizedBox(height: AppSpacing.lg), - _buildIntField( - context, - label: 'Guest Days Between Account Actions', - description: - 'Minimum days between showing account actions to Guest users.', - value: accountActionConfig.guestDaysBetweenAccountActions, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - accountActionConfig: accountActionConfig.copyWith( - guestDaysBetweenAccountActions: value, - ), + const SizedBox(height: AppSpacing.md), + Text( + 'These settings control the display frequency of in-feed account actions (e.g., link account, upgrade prompts), tiered by user role.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: AppSpacing.lg), + TabBar( + tabs: const [ + Tab(text: 'Guest'), + Tab(text: 'Standard User'), + ], + labelColor: Theme.of(context).colorScheme.primary, + unselectedLabelColor: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), + indicatorColor: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + height: 200, // Adjust height as needed + child: TabBarView( + children: [ + _AccountActionConfigForm( + userRole: UserRole.guestUser, + appConfig: appConfig, + onConfigChanged: (newConfig) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: newConfig, + ), + ); + }, + buildIntField: _buildIntField, ), - ), - ); - }, - ), - _buildIntField( - context, - label: 'Standard User Days Between Account Actions', - description: - 'Minimum days between showing account actions to Standard users.', - value: accountActionConfig.standardUserDaysBetweenAccountActions, - onChanged: (value) { - context.read().add( - AppConfigurationFieldChanged( - appConfig: appConfig.copyWith( - accountActionConfig: accountActionConfig.copyWith( - standardUserDaysBetweenAccountActions: value, - ), + _AccountActionConfigForm( + userRole: UserRole.standardUser, + appConfig: appConfig, + onConfigChanged: (newConfig) { + context.read().add( + AppConfigurationFieldChanged( + appConfig: newConfig, + ), + ); + }, + buildIntField: _buildIntField, ), - ), - ); - }, - ), - ], + ], + ), + ), + ], + ), ), ); } @@ -941,3 +805,408 @@ class _AppConfigurationPageState extends State ); } } + +class _UserPreferenceLimitsForm extends StatelessWidget { + const _UserPreferenceLimitsForm({ + required this.userRole, + required this.appConfig, + required this.onConfigChanged, + required this.buildIntField, + }); + + final UserRole userRole; + final AppConfig appConfig; + final ValueChanged onConfigChanged; + final Widget Function( + BuildContext context, { + required String label, + required String description, + required int value, + required ValueChanged onChanged, + }) + buildIntField; + + @override + Widget build(BuildContext context) { + final userPreferenceLimits = appConfig.userPreferenceLimits; + + switch (userRole) { + case UserRole.guestUser: + return Column( + children: [ + buildIntField( + context, + label: 'Guest Followed Items Limit', + description: + 'Max countries, sources, or categories a Guest user can follow (each).', + value: userPreferenceLimits.guestFollowedItemsLimit, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + userPreferenceLimits: userPreferenceLimits.copyWith( + guestFollowedItemsLimit: value, + ), + ), + ); + }, + ), + buildIntField( + context, + label: 'Guest Saved Headlines Limit', + description: 'Max headlines a Guest user can save.', + value: userPreferenceLimits.guestSavedHeadlinesLimit, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + userPreferenceLimits: userPreferenceLimits.copyWith( + guestSavedHeadlinesLimit: value, + ), + ), + ); + }, + ), + ], + ); + case UserRole.standardUser: + return Column( + children: [ + buildIntField( + context, + label: 'Authenticated Followed Items Limit', + description: + 'Max countries, sources, or categories an Authenticated user can follow (each).', + value: userPreferenceLimits.authenticatedFollowedItemsLimit, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + userPreferenceLimits: userPreferenceLimits.copyWith( + authenticatedFollowedItemsLimit: value, + ), + ), + ); + }, + ), + buildIntField( + context, + label: 'Authenticated Saved Headlines Limit', + description: 'Max headlines an Authenticated user can save.', + value: userPreferenceLimits.authenticatedSavedHeadlinesLimit, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + userPreferenceLimits: userPreferenceLimits.copyWith( + authenticatedSavedHeadlinesLimit: value, + ), + ), + ); + }, + ), + ], + ); + case UserRole.premiumUser: + return Column( + children: [ + buildIntField( + context, + label: 'Premium Followed Items Limit', + description: + 'Max countries, sources, or categories a Premium user can follow (each).', + value: userPreferenceLimits.premiumFollowedItemsLimit, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + userPreferenceLimits: userPreferenceLimits.copyWith( + premiumFollowedItemsLimit: value, + ), + ), + ); + }, + ), + buildIntField( + context, + label: 'Premium Saved Headlines Limit', + description: 'Max headlines a Premium user can save.', + value: userPreferenceLimits.premiumSavedHeadlinesLimit, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + userPreferenceLimits: userPreferenceLimits.copyWith( + premiumSavedHeadlinesLimit: value, + ), + ), + ); + }, + ), + ], + ); + case UserRole.admin: + // Admin role might not have specific limits here, or could be + // a separate configuration. For now, return empty. + return const SizedBox.shrink(); + } + } +} + +class _AdConfigForm extends StatelessWidget { + const _AdConfigForm({ + required this.userRole, + required this.appConfig, + required this.onConfigChanged, + required this.buildIntField, + }); + + final UserRole userRole; + final AppConfig appConfig; + final ValueChanged onConfigChanged; + final Widget Function( + BuildContext context, { + required String label, + required String description, + required int value, + required ValueChanged onChanged, + }) + buildIntField; + + @override + Widget build(BuildContext context) { + final adConfig = appConfig.adConfig; + + switch (userRole) { + case UserRole.guestUser: + return Column( + children: [ + buildIntField( + context, + label: 'Guest Ad Frequency', + description: + 'How often an ad can be injected for Guest users (e.g., 5 means after every 5 primary items).', + value: adConfig.guestAdFrequency, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + adConfig: adConfig.copyWith(guestAdFrequency: value), + ), + ); + }, + ), + buildIntField( + context, + label: 'Guest Ad Placement Interval', + description: + 'Minimum primary items before the first ad for Guest users.', + value: adConfig.guestAdPlacementInterval, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + adConfig: adConfig.copyWith( + guestAdPlacementInterval: value, + ), + ), + ); + }, + ), + buildIntField( + context, + label: 'Guest Articles Before Interstitial Ads', + description: + 'Number of articles a Guest user reads before an interstitial ad is shown.', + value: adConfig.guestArticlesToReadBeforeShowingInterstitialAds, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + adConfig: adConfig.copyWith( + guestArticlesToReadBeforeShowingInterstitialAds: value, + ), + ), + ); + }, + ), + ], + ); + case UserRole.standardUser: + return Column( + children: [ + buildIntField( + context, + label: 'Authenticated Ad Frequency', + description: + 'How often an ad can be injected for Authenticated users.', + value: adConfig.authenticatedAdFrequency, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + adConfig: adConfig.copyWith( + authenticatedAdFrequency: value, + ), + ), + ); + }, + ), + buildIntField( + context, + label: 'Authenticated Ad Placement Interval', + description: + 'Minimum primary items before the first ad for Authenticated users.', + value: adConfig.authenticatedAdPlacementInterval, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + adConfig: adConfig.copyWith( + authenticatedAdPlacementInterval: value, + ), + ), + ); + }, + ), + buildIntField( + context, + label: 'Standard User Articles Before Interstitial Ads', + description: + 'Number of articles a Standard user reads before an interstitial ad is shown.', + value: adConfig + .standardUserArticlesToReadBeforeShowingInterstitialAds, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + adConfig: adConfig.copyWith( + standardUserArticlesToReadBeforeShowingInterstitialAds: + value, + ), + ), + ); + }, + ), + ], + ); + case UserRole.premiumUser: + return Column( + children: [ + buildIntField( + context, + label: 'Premium Ad Frequency', + description: + 'How often an ad can be injected for Premium users (0 for no ads).', + value: adConfig.premiumAdFrequency, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + adConfig: adConfig.copyWith(premiumAdFrequency: value), + ), + ); + }, + ), + buildIntField( + context, + label: 'Premium Ad Placement Interval', + description: + 'Minimum primary items before the first ad for Premium users.', + value: adConfig.premiumAdPlacementInterval, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + adConfig: adConfig.copyWith( + premiumAdPlacementInterval: value, + ), + ), + ); + }, + ), + buildIntField( + context, + label: 'Premium User Articles Before Interstitial Ads', + description: + 'Number of articles a Premium user reads before an interstitial ad is shown.', + value: adConfig + .premiumUserArticlesToReadBeforeShowingInterstitialAds, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + adConfig: adConfig.copyWith( + premiumUserArticlesToReadBeforeShowingInterstitialAds: + value, + ), + ), + ); + }, + ), + ], + ); + case UserRole.admin: + return const SizedBox.shrink(); + } + } +} + +class _AccountActionConfigForm extends StatelessWidget { + const _AccountActionConfigForm({ + required this.userRole, + required this.appConfig, + required this.onConfigChanged, + required this.buildIntField, + }); + + final UserRole userRole; + final AppConfig appConfig; + final ValueChanged onConfigChanged; + final Widget Function( + BuildContext context, { + required String label, + required String description, + required int value, + required ValueChanged onChanged, + }) + buildIntField; + + @override + Widget build(BuildContext context) { + final accountActionConfig = appConfig.accountActionConfig; + + switch (userRole) { + case UserRole.guestUser: + return Column( + children: [ + buildIntField( + context, + label: 'Guest Days Between Account Actions', + description: + 'Minimum days between showing account actions to Guest users.', + value: accountActionConfig.guestDaysBetweenAccountActions, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + accountActionConfig: accountActionConfig.copyWith( + guestDaysBetweenAccountActions: value, + ), + ), + ); + }, + ), + ], + ); + case UserRole.standardUser: + return Column( + children: [ + buildIntField( + context, + label: 'Standard User Days Between Account Actions', + description: + 'Minimum days between showing account actions to Standard users.', + value: accountActionConfig.standardUserDaysBetweenAccountActions, + onChanged: (value) { + onConfigChanged( + appConfig.copyWith( + accountActionConfig: accountActionConfig.copyWith( + standardUserDaysBetweenAccountActions: value, + ), + ), + ); + }, + ), + ], + ); + case UserRole.premiumUser: + case UserRole.admin: + return const SizedBox.shrink(); + } + } +} From 157be29634608609f16ee6758551cd0c60a8405e Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 29 Jun 2025 05:45:15 +0100 Subject: [PATCH 6/8] fix: he "Discard Changes" button now functions correctly, visually reverting the form fields to their original or last-saved state. This was achieved by converting the relevant sub-forms to StatefulWidgets and managing their TextFormFields with TextEditingControllers, ensuring proper UI synchronization with the bloc's state. --- .../view/app_configuration_page.dart | 437 +++++++++++++++--- 1 file changed, 375 insertions(+), 62 deletions(-) diff --git a/lib/app_configuration/view/app_configuration_page.dart b/lib/app_configuration/view/app_configuration_page.dart index 9b5a539a..fdc328df 100644 --- a/lib/app_configuration/view/app_configuration_page.dart +++ b/lib/app_configuration/view/app_configuration_page.dart @@ -652,6 +652,7 @@ class _AppConfigurationPageState extends State required String description, required int value, required ValueChanged onChanged, + TextEditingController? controller, // Add controller parameter }) { return Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), @@ -671,7 +672,10 @@ class _AppConfigurationPageState extends State ), const SizedBox(height: AppSpacing.xs), TextFormField( - initialValue: value.toString(), + controller: controller, // Use controller + initialValue: controller == null + ? value.toString() + : null, // Only use initialValue if no controller keyboardType: TextInputType.number, decoration: const InputDecoration( border: OutlineInputBorder(), @@ -695,6 +699,7 @@ class _AppConfigurationPageState extends State required String description, required String? value, required ValueChanged onChanged, + TextEditingController? controller, // Add controller parameter }) { return Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), @@ -714,7 +719,10 @@ class _AppConfigurationPageState extends State ), const SizedBox(height: AppSpacing.xs), TextFormField( - initialValue: value, + controller: controller, // Use controller + initialValue: controller == null + ? value + : null, // Only use initialValue if no controller decoration: const InputDecoration( border: OutlineInputBorder(), isDense: true, @@ -806,7 +814,7 @@ class _AppConfigurationPageState extends State } } -class _UserPreferenceLimitsForm extends StatelessWidget { +class _UserPreferenceLimitsForm extends StatefulWidget { const _UserPreferenceLimitsForm({ required this.userRole, required this.appConfig, @@ -823,119 +831,223 @@ class _UserPreferenceLimitsForm extends StatelessWidget { required String description, required int value, required ValueChanged onChanged, + TextEditingController? controller, }) buildIntField; + @override + State<_UserPreferenceLimitsForm> createState() => + _UserPreferenceLimitsFormState(); +} + +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; + + @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(), + ); + } + + @override + void didUpdateWidget(covariant _UserPreferenceLimitsForm oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.appConfig.userPreferenceLimits != + oldWidget.appConfig.userPreferenceLimits) { + _guestFollowedItemsLimitController.text = widget + .appConfig + .userPreferenceLimits + .guestFollowedItemsLimit + .toString(); + _guestSavedHeadlinesLimitController.text = widget + .appConfig + .userPreferenceLimits + .guestSavedHeadlinesLimit + .toString(); + _authenticatedFollowedItemsLimitController.text = widget + .appConfig + .userPreferenceLimits + .authenticatedFollowedItemsLimit + .toString(); + _authenticatedSavedHeadlinesLimitController.text = widget + .appConfig + .userPreferenceLimits + .authenticatedSavedHeadlinesLimit + .toString(); + _premiumFollowedItemsLimitController.text = widget + .appConfig + .userPreferenceLimits + .premiumFollowedItemsLimit + .toString(); + _premiumSavedHeadlinesLimitController.text = widget + .appConfig + .userPreferenceLimits + .premiumSavedHeadlinesLimit + .toString(); + } + } + + @override + void dispose() { + _guestFollowedItemsLimitController.dispose(); + _guestSavedHeadlinesLimitController.dispose(); + _authenticatedFollowedItemsLimitController.dispose(); + _authenticatedSavedHeadlinesLimitController.dispose(); + _premiumFollowedItemsLimitController.dispose(); + _premiumSavedHeadlinesLimitController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - final userPreferenceLimits = appConfig.userPreferenceLimits; + final userPreferenceLimits = widget.appConfig.userPreferenceLimits; - switch (userRole) { + switch (widget.userRole) { case UserRole.guestUser: return Column( children: [ - buildIntField( + widget.buildIntField( context, label: 'Guest Followed Items Limit', description: 'Max countries, sources, or categories a Guest user can follow (each).', value: userPreferenceLimits.guestFollowedItemsLimit, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( userPreferenceLimits: userPreferenceLimits.copyWith( guestFollowedItemsLimit: value, ), ), ); }, + controller: _guestFollowedItemsLimitController, ), - buildIntField( + widget.buildIntField( context, label: 'Guest Saved Headlines Limit', description: 'Max headlines a Guest user can save.', value: userPreferenceLimits.guestSavedHeadlinesLimit, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( userPreferenceLimits: userPreferenceLimits.copyWith( guestSavedHeadlinesLimit: value, ), ), ); }, + controller: _guestSavedHeadlinesLimitController, ), ], ); case UserRole.standardUser: return Column( children: [ - buildIntField( + widget.buildIntField( context, label: 'Authenticated Followed Items Limit', description: 'Max countries, sources, or categories an Authenticated user can follow (each).', value: userPreferenceLimits.authenticatedFollowedItemsLimit, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( userPreferenceLimits: userPreferenceLimits.copyWith( authenticatedFollowedItemsLimit: value, ), ), ); }, + controller: _authenticatedFollowedItemsLimitController, ), - buildIntField( + widget.buildIntField( context, label: 'Authenticated Saved Headlines Limit', description: 'Max headlines an Authenticated user can save.', value: userPreferenceLimits.authenticatedSavedHeadlinesLimit, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( userPreferenceLimits: userPreferenceLimits.copyWith( authenticatedSavedHeadlinesLimit: value, ), ), ); }, + controller: _authenticatedSavedHeadlinesLimitController, ), ], ); case UserRole.premiumUser: return Column( children: [ - buildIntField( + widget.buildIntField( context, label: 'Premium Followed Items Limit', description: 'Max countries, sources, or categories a Premium user can follow (each).', value: userPreferenceLimits.premiumFollowedItemsLimit, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( userPreferenceLimits: userPreferenceLimits.copyWith( premiumFollowedItemsLimit: value, ), ), ); }, + controller: _premiumFollowedItemsLimitController, ), - buildIntField( + widget.buildIntField( context, label: 'Premium Saved Headlines Limit', description: 'Max headlines a Premium user can save.', value: userPreferenceLimits.premiumSavedHeadlinesLimit, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( userPreferenceLimits: userPreferenceLimits.copyWith( premiumSavedHeadlinesLimit: value, ), ), ); }, + controller: _premiumSavedHeadlinesLimitController, ), ], ); @@ -947,7 +1059,7 @@ class _UserPreferenceLimitsForm extends StatelessWidget { } } -class _AdConfigForm extends StatelessWidget { +class _AdConfigForm extends StatefulWidget { const _AdConfigForm({ required this.userRole, required this.appConfig, @@ -964,101 +1076,242 @@ class _AdConfigForm extends StatelessWidget { required String description, required int value, required ValueChanged onChanged, + TextEditingController? controller, }) 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 + _standardUserArticlesToReadBeforeShowingInterstitialAdsController; + late final TextEditingController _premiumAdFrequencyController; + late final TextEditingController _premiumAdPlacementIntervalController; + late final TextEditingController + _premiumUserArticlesToReadBeforeShowingInterstitialAdsController; + + @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(), + ); + } + + @override + void didUpdateWidget(covariant _AdConfigForm oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.appConfig.adConfig != oldWidget.appConfig.adConfig) { + _guestAdFrequencyController.text = widget + .appConfig + .adConfig + .guestAdFrequency + .toString(); + _guestAdPlacementIntervalController.text = widget + .appConfig + .adConfig + .guestAdPlacementInterval + .toString(); + _guestArticlesToReadBeforeShowingInterstitialAdsController.text = widget + .appConfig + .adConfig + .guestArticlesToReadBeforeShowingInterstitialAds + .toString(); + _authenticatedAdFrequencyController.text = widget + .appConfig + .adConfig + .authenticatedAdFrequency + .toString(); + _authenticatedAdPlacementIntervalController.text = widget + .appConfig + .adConfig + .authenticatedAdPlacementInterval + .toString(); + _standardUserArticlesToReadBeforeShowingInterstitialAdsController.text = + widget + .appConfig + .adConfig + .standardUserArticlesToReadBeforeShowingInterstitialAds + .toString(); + _premiumAdFrequencyController.text = widget + .appConfig + .adConfig + .premiumAdFrequency + .toString(); + _premiumAdPlacementIntervalController.text = widget + .appConfig + .adConfig + .premiumAdPlacementInterval + .toString(); + _premiumUserArticlesToReadBeforeShowingInterstitialAdsController.text = + widget + .appConfig + .adConfig + .premiumUserArticlesToReadBeforeShowingInterstitialAds + .toString(); + } + } + + @override + void dispose() { + _guestAdFrequencyController.dispose(); + _guestAdPlacementIntervalController.dispose(); + _guestArticlesToReadBeforeShowingInterstitialAdsController.dispose(); + _authenticatedAdFrequencyController.dispose(); + _authenticatedAdPlacementIntervalController.dispose(); + _standardUserArticlesToReadBeforeShowingInterstitialAdsController.dispose(); + _premiumAdFrequencyController.dispose(); + _premiumAdPlacementIntervalController.dispose(); + _premiumUserArticlesToReadBeforeShowingInterstitialAdsController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - final adConfig = appConfig.adConfig; + final adConfig = widget.appConfig.adConfig; - switch (userRole) { + switch (widget.userRole) { case UserRole.guestUser: return Column( children: [ - buildIntField( + widget.buildIntField( context, label: 'Guest Ad Frequency', description: 'How often an ad can be injected for Guest users (e.g., 5 means after every 5 primary items).', value: adConfig.guestAdFrequency, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( adConfig: adConfig.copyWith(guestAdFrequency: value), ), ); }, + controller: _guestAdFrequencyController, ), - buildIntField( + widget.buildIntField( context, label: 'Guest Ad Placement Interval', description: 'Minimum primary items before the first ad for Guest users.', value: adConfig.guestAdPlacementInterval, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( adConfig: adConfig.copyWith( guestAdPlacementInterval: value, ), ), ); }, + controller: _guestAdPlacementIntervalController, ), - buildIntField( + widget.buildIntField( context, label: 'Guest Articles Before Interstitial Ads', description: 'Number of articles a Guest user reads before an interstitial ad is shown.', value: adConfig.guestArticlesToReadBeforeShowingInterstitialAds, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( adConfig: adConfig.copyWith( guestArticlesToReadBeforeShowingInterstitialAds: value, ), ), ); }, + controller: + _guestArticlesToReadBeforeShowingInterstitialAdsController, ), ], ); case UserRole.standardUser: return Column( children: [ - buildIntField( + widget.buildIntField( context, label: 'Authenticated Ad Frequency', description: 'How often an ad can be injected for Authenticated users.', value: adConfig.authenticatedAdFrequency, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( adConfig: adConfig.copyWith( authenticatedAdFrequency: value, ), ), ); }, + controller: _authenticatedAdFrequencyController, ), - buildIntField( + widget.buildIntField( context, label: 'Authenticated Ad Placement Interval', description: 'Minimum primary items before the first ad for Authenticated users.', value: adConfig.authenticatedAdPlacementInterval, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( adConfig: adConfig.copyWith( authenticatedAdPlacementInterval: value, ), ), ); }, + controller: _authenticatedAdPlacementIntervalController, ), - buildIntField( + widget.buildIntField( context, label: 'Standard User Articles Before Interstitial Ads', description: @@ -1066,8 +1319,8 @@ class _AdConfigForm extends StatelessWidget { value: adConfig .standardUserArticlesToReadBeforeShowingInterstitialAds, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( adConfig: adConfig.copyWith( standardUserArticlesToReadBeforeShowingInterstitialAds: value, @@ -1075,43 +1328,47 @@ class _AdConfigForm extends StatelessWidget { ), ); }, + controller: + _standardUserArticlesToReadBeforeShowingInterstitialAdsController, ), ], ); case UserRole.premiumUser: return Column( children: [ - buildIntField( + widget.buildIntField( context, label: 'Premium Ad Frequency', description: 'How often an ad can be injected for Premium users (0 for no ads).', value: adConfig.premiumAdFrequency, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( adConfig: adConfig.copyWith(premiumAdFrequency: value), ), ); }, + controller: _premiumAdFrequencyController, ), - buildIntField( + widget.buildIntField( context, label: 'Premium Ad Placement Interval', description: 'Minimum primary items before the first ad for Premium users.', value: adConfig.premiumAdPlacementInterval, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( adConfig: adConfig.copyWith( premiumAdPlacementInterval: value, ), ), ); }, + controller: _premiumAdPlacementIntervalController, ), - buildIntField( + widget.buildIntField( context, label: 'Premium User Articles Before Interstitial Ads', description: @@ -1119,8 +1376,8 @@ class _AdConfigForm extends StatelessWidget { value: adConfig .premiumUserArticlesToReadBeforeShowingInterstitialAds, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( adConfig: adConfig.copyWith( premiumUserArticlesToReadBeforeShowingInterstitialAds: value, @@ -1128,6 +1385,8 @@ class _AdConfigForm extends StatelessWidget { ), ); }, + controller: + _premiumUserArticlesToReadBeforeShowingInterstitialAdsController, ), ], ); @@ -1137,7 +1396,7 @@ class _AdConfigForm extends StatelessWidget { } } -class _AccountActionConfigForm extends StatelessWidget { +class _AccountActionConfigForm extends StatefulWidget { const _AccountActionConfigForm({ required this.userRole, required this.appConfig, @@ -1154,53 +1413,107 @@ class _AccountActionConfigForm extends StatelessWidget { required String description, required int value, required ValueChanged onChanged, + TextEditingController? controller, }) buildIntField; + @override + State<_AccountActionConfigForm> createState() => + _AccountActionConfigFormState(); +} + +class _AccountActionConfigFormState extends State<_AccountActionConfigForm> { + late final TextEditingController _guestDaysBetweenAccountActionsController; + late final TextEditingController + _standardUserDaysBetweenAccountActionsController; + + @override + void initState() { + super.initState(); + _guestDaysBetweenAccountActionsController = TextEditingController( + text: widget.appConfig.accountActionConfig.guestDaysBetweenAccountActions + .toString(), + ); + _standardUserDaysBetweenAccountActionsController = TextEditingController( + text: widget + .appConfig + .accountActionConfig + .standardUserDaysBetweenAccountActions + .toString(), + ); + } + + @override + void didUpdateWidget(covariant _AccountActionConfigForm oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.appConfig.accountActionConfig != + oldWidget.appConfig.accountActionConfig) { + _guestDaysBetweenAccountActionsController.text = widget + .appConfig + .accountActionConfig + .guestDaysBetweenAccountActions + .toString(); + _standardUserDaysBetweenAccountActionsController.text = widget + .appConfig + .accountActionConfig + .standardUserDaysBetweenAccountActions + .toString(); + } + } + + @override + void dispose() { + _guestDaysBetweenAccountActionsController.dispose(); + _standardUserDaysBetweenAccountActionsController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - final accountActionConfig = appConfig.accountActionConfig; + final accountActionConfig = widget.appConfig.accountActionConfig; - switch (userRole) { + switch (widget.userRole) { case UserRole.guestUser: return Column( children: [ - buildIntField( + widget.buildIntField( context, label: 'Guest Days Between Account Actions', description: 'Minimum days between showing account actions to Guest users.', value: accountActionConfig.guestDaysBetweenAccountActions, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( accountActionConfig: accountActionConfig.copyWith( guestDaysBetweenAccountActions: value, ), ), ); }, + controller: _guestDaysBetweenAccountActionsController, ), ], ); case UserRole.standardUser: return Column( children: [ - buildIntField( + widget.buildIntField( context, label: 'Standard User Days Between Account Actions', description: 'Minimum days between showing account actions to Standard users.', value: accountActionConfig.standardUserDaysBetweenAccountActions, onChanged: (value) { - onConfigChanged( - appConfig.copyWith( + widget.onConfigChanged( + widget.appConfig.copyWith( accountActionConfig: accountActionConfig.copyWith( standardUserDaysBetweenAccountActions: value, ), ), ); }, + controller: _standardUserDaysBetweenAccountActionsController, ), ], ); From ca4ac81349da9e4d65e47c62ce48576bca004c96 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 29 Jun 2025 05:46:45 +0100 Subject: [PATCH 7/8] style: format --- lib/router/router.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index 9d996c3a..8ab36b7c 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -98,8 +98,7 @@ GoRouter createRouter({ builder: (BuildContext context, GoRouterState state) { final l10n = context.l10n; const headline = 'Sign In to Dashboard'; - const subHeadline = - 'Enter your email to get a verification code.'; + const subHeadline = 'Enter your email to get a verification code.'; const showAnonymousButton = false; return BlocProvider( From e36e57f7a04b7ffa942496979cfc3e099ab3b120 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 29 Jun 2025 05:51:05 +0100 Subject: [PATCH 8/8] fix(config): clear success flag correctly - Fixed snackbar display logic - Cleared flag after showing snackbar --- lib/app_configuration/view/app_configuration_page.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/app_configuration/view/app_configuration_page.dart b/lib/app_configuration/view/app_configuration_page.dart index fdc328df..13ed36f4 100644 --- a/lib/app_configuration/view/app_configuration_page.dart +++ b/lib/app_configuration/view/app_configuration_page.dart @@ -79,9 +79,7 @@ class _AppConfigurationPageState extends State ); // Clear the showSaveSuccess flag after showing the snackbar context.read().add( - const AppConfigurationFieldChanged( - appConfig: null, // No actual config change, just clear flag - ), + const AppConfigurationFieldChanged(), ); } else if (state.status == AppConfigurationStatus.failure) { ScaffoldMessenger.of(context)