Skip to content

Enhance content management background fetching #56

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Aug 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/app/view/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/content_manageme
import 'package:flutter_news_app_web_dashboard_full_source_code/dashboard/bloc/dashboard_bloc.dart';
import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart';
import 'package:flutter_news_app_web_dashboard_full_source_code/router/router.dart';
import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/throttled_fetching_service.dart';
import 'package:go_router/go_router.dart';
import 'package:kv_storage_service/kv_storage_service.dart';
import 'package:logging/logging.dart';
Expand Down Expand Up @@ -79,6 +80,7 @@ class App extends StatelessWidget {
RepositoryProvider.value(value: _countriesRepository),
RepositoryProvider.value(value: _languagesRepository),
RepositoryProvider.value(value: _kvStorageService),
RepositoryProvider(create: (context) => const ThrottledFetchingService()),
],
child: MultiBlocProvider(
providers: [
Expand Down Expand Up @@ -110,6 +112,7 @@ class App extends StatelessWidget {
sourcesRepository: context.read<DataRepository<Source>>(),
countriesRepository: context.read<DataRepository<Country>>(),
languagesRepository: context.read<DataRepository<Language>>(),
fetchingService: context.read<ThrottledFetchingService>(),
)..add(const SharedDataRequested()),
),
BlocProvider(
Expand Down
72 changes: 34 additions & 38 deletions lib/content_management/bloc/content_management_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:bloc/bloc.dart';
import 'package:core/core.dart';
import 'package:data_repository/data_repository.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/throttled_fetching_service.dart';

part 'content_management_event.dart';
part 'content_management_state.dart';
Expand All @@ -26,12 +27,14 @@ class ContentManagementBloc
required DataRepository<Source> sourcesRepository,
required DataRepository<Country> countriesRepository,
required DataRepository<Language> languagesRepository,
}) : _headlinesRepository = headlinesRepository,
_topicsRepository = topicsRepository,
_sourcesRepository = sourcesRepository,
_countriesRepository = countriesRepository,
_languagesRepository = languagesRepository,
super(const ContentManagementState()) {
required ThrottledFetchingService fetchingService,
}) : _headlinesRepository = headlinesRepository,
_topicsRepository = topicsRepository,
_sourcesRepository = sourcesRepository,
_countriesRepository = countriesRepository,
_languagesRepository = languagesRepository,
_fetchingService = fetchingService,
super(const ContentManagementState()) {
on<SharedDataRequested>(_onSharedDataRequested);
on<ContentManagementTabChanged>(_onContentManagementTabChanged);
on<LoadHeadlinesRequested>(_onLoadHeadlinesRequested);
Expand All @@ -50,39 +53,32 @@ class ContentManagementBloc
final DataRepository<Source> _sourcesRepository;
final DataRepository<Country> _countriesRepository;
final DataRepository<Language> _languagesRepository;

// --- Background Data Fetching for countries/languages for the ui Dropdown ---
//
// The DropdownButtonFormField widget does not natively support on-scroll
// pagination. To preserve UI consistency across the application, this BLoC
// employs an event-driven background fetching mechanism.
final ThrottledFetchingService _fetchingService;

/// Handles the pre-fetching of shared data required for the content
/// management section.
///
/// **Strategy Rationale (The "Why"):**
/// This pre-fetching strategy is a direct result of a UI component choice
/// made to preserve visual consistency across the application. The standard
/// `DropdownButtonFormField` is used for selection fields in forms.
/// A key limitation of this widget is its lack of native support for
/// on-scroll pagination or dynamic data loading.
///
/// To work around this, and to ensure a seamless user experience without
/// loading delays when a form is opened, we must load the entire dataset
/// for these dropdowns (e.g., all countries, all languages) into the state
/// ahead of time.
///
/// **Implementation (The "How"):**
/// To execute this pre-fetch efficiently, this handler utilizes the
/// `ThrottledFetchingService`. This service fetches all pages of a given
/// resource in parallel, which dramatically reduces the load time compared
/// to fetching them sequentially, making the upfront data load manageable.
Future<void> _onSharedDataRequested(
SharedDataRequested event,
Emitter<ContentManagementState> emit,
) async {
// Helper function to fetch all items of a given type.
Future<List<T>> fetchAll<T>({
required DataRepository<T> repository,
required List<SortOption> sort,
}) async {
final allItems = <T>[];
String? cursor;
bool hasMore;

do {
final response = await repository.readAll(
sort: sort,
pagination: PaginationOptions(cursor: cursor),
filter: {'status': ContentStatus.active.name},
);
allItems.addAll(response.items);
cursor = response.cursor;
hasMore = response.hasMore;
} while (hasMore);

return allItems;
}

// Check if data is already loaded or is currently loading to prevent
// redundant fetches.
if (state.allCountriesStatus == ContentManagementStatus.success &&
Expand All @@ -99,13 +95,13 @@ class ContentManagementBloc
);

try {
// Fetch both lists in parallel.
// Fetch both lists in parallel using the dedicated fetching service.
final results = await Future.wait([
fetchAll<Country>(
_fetchingService.fetchAll<Country>(
repository: _countriesRepository,
sort: [const SortOption('name', SortOrder.asc)],
),
fetchAll<Language>(
_fetchingService.fetchAll<Language>(
repository: _languagesRepository,
sort: [const SortOption('name', SortOrder.asc)],
),
Expand Down
84 changes: 48 additions & 36 deletions lib/content_management/view/create_headline_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,9 @@ class CreateHeadlinePage extends StatelessWidget {
// The list of all countries is fetched once and cached in the
// ContentManagementBloc. We read it here and provide it to the
// CreateHeadlineBloc.
final allCountries = context
.read<ContentManagementBloc>()
.state
.allCountries;
final contentManagementState = context.watch<ContentManagementBloc>().state;
final allCountries = contentManagementState.allCountries;

return BlocProvider(
create: (context) => CreateHeadlineBloc(
headlinesRepository: context.read<DataRepository<Headline>>(),
Expand Down Expand Up @@ -221,40 +220,53 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> {
.add(CreateHeadlineTopicChanged(value)),
),
const SizedBox(height: AppSpacing.lg),
DropdownButtonFormField<Country?>(
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: Row(
children: [
SizedBox(
width: 32,
height: 20,
child: Image.network(
country.flagUrl,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
const Icon(Icons.flag),
),
BlocBuilder<ContentManagementBloc, ContentManagementState>(
builder: (context, contentState) {
final isLoading = contentState.allCountriesStatus ==
ContentManagementStatus.loading;
return DropdownButtonFormField<Country?>(
value: state.eventCountry,
decoration: InputDecoration(
labelText: l10n.countryName,
border: const OutlineInputBorder(),
helperText:
isLoading ? l10n.loadingFullList : null,
),
items: [
DropdownMenuItem(
value: null,
child: Text(l10n.none),
),
...state.countries.map(
(country) => DropdownMenuItem(
value: country,
child: Row(
children: [
SizedBox(
width: 32,
height: 20,
child: Image.network(
country.flagUrl,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
const Icon(Icons.flag),
),
),
const SizedBox(width: AppSpacing.md),
Text(country.name),
],
),
const SizedBox(width: AppSpacing.md),
Text(country.name),
],
),
),
),
),
],
onChanged: (value) => context
.read<CreateHeadlineBloc>()
.add(CreateHeadlineCountryChanged(value)),
],
onChanged: isLoading
? null
: (value) => context
.read<CreateHeadlineBloc>()
.add(CreateHeadlineCountryChanged(value)),
);
},
),
const SizedBox(height: AppSpacing.lg),
DropdownButtonFormField<ContentStatus>(
Expand Down
119 changes: 70 additions & 49 deletions lib/content_management/view/create_source_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -168,24 +168,35 @@ class _CreateSourceViewState extends State<_CreateSourceView> {
.add(CreateSourceUrlChanged(value)),
),
const SizedBox(height: AppSpacing.lg),
DropdownButtonFormField<Language?>(
value: state.language,
decoration: InputDecoration(
labelText: l10n.language,
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem(value: null, child: Text(l10n.none)),
...state.languages.map(
(language) => DropdownMenuItem(
value: language,
child: Text(language.name),
BlocBuilder<ContentManagementBloc, ContentManagementState>(
builder: (context, contentState) {
final isLoading = contentState.allLanguagesStatus ==
ContentManagementStatus.loading;
return DropdownButtonFormField<Language?>(
value: state.language,
decoration: InputDecoration(
labelText: l10n.language,
border: const OutlineInputBorder(),
helperText:
isLoading ? l10n.loadingFullList : null,
),
),
],
onChanged: (value) => context
.read<CreateSourceBloc>()
.add(CreateSourceLanguageChanged(value)),
items: [
DropdownMenuItem(
value: null, child: Text(l10n.none)),
...state.languages.map(
(language) => DropdownMenuItem(
value: language,
child: Text(language.name),
),
),
],
onChanged: isLoading
? null
: (value) => context
.read<CreateSourceBloc>()
.add(CreateSourceLanguageChanged(value)),
);
},
),
const SizedBox(height: AppSpacing.lg),
DropdownButtonFormField<SourceType?>(
Expand All @@ -208,40 +219,50 @@ class _CreateSourceViewState extends State<_CreateSourceView> {
.add(CreateSourceTypeChanged(value)),
),
const SizedBox(height: AppSpacing.lg),
DropdownButtonFormField<Country?>(
value: state.headquarters,
decoration: InputDecoration(
labelText: l10n.headquarters,
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem(value: null, child: Text(l10n.none)),
...state.countries.map(
(country) => DropdownMenuItem(
value: country,
child: Row(
children: [
SizedBox(
width: 32,
height: 20,
child: Image.network(
country.flagUrl,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
const Icon(Icons.flag),
),
BlocBuilder<ContentManagementBloc, ContentManagementState>(
builder: (context, contentState) {
final isLoading = contentState.allCountriesStatus ==
ContentManagementStatus.loading;
return DropdownButtonFormField<Country?>(
value: state.headquarters,
decoration: InputDecoration(
labelText: l10n.headquarters,
border: const OutlineInputBorder(),
helperText:
isLoading ? l10n.loadingFullList : null,
),
items: [
DropdownMenuItem(
value: null, child: Text(l10n.none)),
...state.countries.map(
(country) => DropdownMenuItem(
value: country,
child: Row(
children: [
SizedBox(
width: 32,
height: 20,
child: Image.network(
country.flagUrl,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
const Icon(Icons.flag),
),
),
const SizedBox(width: AppSpacing.md),
Text(country.name),
],
),
const SizedBox(width: AppSpacing.md),
Text(country.name),
],
),
),
),
),
],
onChanged: (value) => context
.read<CreateSourceBloc>()
.add(CreateSourceHeadquartersChanged(value)),
],
onChanged: isLoading
? null
: (value) => context.read<CreateSourceBloc>().add(
CreateSourceHeadquartersChanged(value)),
);
},
),
const SizedBox(height: AppSpacing.lg),
DropdownButtonFormField<ContentStatus>(
Expand Down
Loading
Loading