Skip to content

Commit 48d1fec

Browse files
authored
Merge pull request #192 from flutter-news-app-full-source-code/fix/ads-article-pa-pb-pa-and-feed-decorator-dismiss
Fix/ads article pa pb pa and feed decorator dismiss
2 parents f931ce2 + c0ab5f7 commit 48d1fec

File tree

15 files changed

+307
-514
lines changed

15 files changed

+307
-514
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ migrate_working_dir/
2020
.classpath
2121
.project
2222
.settings/
23-
.vscode/*
23+
.vscode/
2424

2525
# packages file containing multi-root paths
2626
.packages.generated

.vscode/extensions.json

Lines changed: 0 additions & 9 deletions
This file was deleted.

.vscode/launch.json

Lines changed: 0 additions & 34 deletions
This file was deleted.

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,6 @@ Built on a modern, multi-layered architecture that prioritizes clarity, testabil
9999
### 🛠️ Production-Ready Environment Tooling
100100
Utilizes compile-time variables (`--dart-define`) to seamlessly switch between `production`, `development`, and `demo` environments.
101101
- **Error-Proof Configuration:** This approach ensures environment-specific settings like API endpoints are set at build time, preventing accidental release of development configurations.
102-
- **Streamlined Workflow:** Includes pre-configured VS Code `launch.json` profiles for one-click running and debugging in any environment.
103102
> **Your Advantage:** A robust, professional environment setup that streamlines the development-to-production pipeline and prevents common configuration mistakes.
104103
105104
---

lib/ads/widgets/feed_ad_loader_widget.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ class _FeedAdLoaderWidgetState extends State<FeedAdLoaderWidget> {
183183

184184
// Define the maximum age for a cached ad before it's considered stale.
185185
// TODO(fulleni): This should be sourced from RemoteConfig.
186-
const adMaxAge = Duration(minutes: 5);
186+
const adMaxAge = Duration(minutes: 1);
187187

188188
// Attempt to retrieve the ad from the cache using the contextKey.
189189
var cachedAd = _adCacheService.getAd(

lib/ads/widgets/in_article_ad_loader_widget.dart

Lines changed: 79 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import 'package:core/core.dart';
44
import 'package:flutter/material.dart';
55
import 'package:flutter_bloc/flutter_bloc.dart';
66
import 'package:flutter_news_app_mobile_client_full_source_code/ads/ad_service.dart';
7-
import 'package:flutter_news_app_mobile_client_full_source_code/ads/inline_ad_cache_service.dart';
87
import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart';
98
import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/banner_ad.dart';
109
import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/inline_ad.dart';
@@ -19,33 +18,29 @@ import 'package:logging/logging.dart';
1918
import 'package:ui_kit/ui_kit.dart';
2019

2120
/// {@template in_article_ad_loader_widget}
22-
/// A self-contained widget responsible for loading and displaying an in-article
23-
/// ad.
21+
/// A self-contained, stateful widget that manages the entire lifecycle
22+
/// of a single in-article ad slot.
2423
///
25-
/// This widget handles the entire lifecycle of a single in-article ad slot.
26-
/// It attempts to retrieve a cached [InlineAd] first. If no ad is cached,
27-
/// it requests a new one from the [AdService] using `getInArticleAd` and stores
28-
/// it in the cache upon success. It manages its own loading and error states.
24+
/// This widget is designed to be robust and efficient. It fetches an
25+
/// [InlineAd] from the [AdService] in its `initState` method and
26+
/// stores it in its own local state. Crucially, it is responsible
27+
/// for disposing of the loaded ad's resources in its `dispose` method.
2928
///
30-
/// This approach decouples ad loading from the BLoC and ensures that native
31-
/// ad resources are managed efficiently, preventing crashes and improving
32-
/// scrolling performance in lists.
29+
/// This approach ensures that the ad's lifecycle is tightly coupled
30+
/// with the widget's lifecycle, preventing ad cache collisions when
31+
/// multiple instances of the same article page are in the navigation
32+
/// stack. It also ensures proper resource cleanup when the widget
33+
/// is removed from the widget tree.
3334
/// {@endtemplate}
3435
class InArticleAdLoaderWidget extends StatefulWidget {
3536
/// {@macro in_article_ad_loader_widget}
3637
const InArticleAdLoaderWidget({
37-
required this.contextKey,
3838
required this.slotType,
3939
required this.adThemeStyle,
4040
required this.adConfig,
4141
super.key,
4242
});
4343

44-
/// A unique key representing the ad context this ad belongs to, typically the
45-
/// article ID. This is used to scope the ad within the
46-
/// [InlineAdCacheService].
47-
final String contextKey;
48-
4944
/// The type of the in-article ad slot.
5045
final InArticleAdSlotType slotType;
5146

@@ -61,68 +56,88 @@ class InArticleAdLoaderWidget extends StatefulWidget {
6156
}
6257

6358
class _InArticleAdLoaderWidgetState extends State<InArticleAdLoaderWidget> {
59+
/// The currently loaded inline ad object.
60+
/// This is managed entirely by this widget's state.
6461
InlineAd? _loadedAd;
6562
bool _isLoading = true;
6663
bool _hasError = false;
6764
final Logger _logger = Logger('InArticleAdLoaderWidget');
68-
late final InlineAdCacheService _adCacheService;
6965
late final AdService _adService;
7066

7167
Completer<void>? _loadAdCompleter;
7268

7369
@override
7470
void initState() {
7571
super.initState();
76-
_adCacheService = context.read<InlineAdCacheService>();
72+
// AdService is used to fetch new ads and dispose of them.
7773
_adService = context.read<AdService>();
7874
_loadAd();
7975
}
8076

8177
@override
8278
void didUpdateWidget(covariant InArticleAdLoaderWidget oldWidget) {
8379
super.didUpdateWidget(oldWidget);
84-
// If the slotType or contextKey changes, it means this widget is being
85-
// reused for a different ad slot or context. We need to cancel any ongoing
86-
// load for the old ad and initiate a new load for the new ad.
80+
// If the slotType or adConfig changes, it means this widget is
81+
// being reused for a different ad slot or its configuration has
82+
// been updated. We need to cancel any ongoing load for the old ad
83+
// and initiate a new load for the new ad.
8784
// Also, if the adConfig changes, we should re-evaluate and potentially
8885
// reload.
89-
if (widget.contextKey != oldWidget.contextKey ||
90-
widget.slotType != oldWidget.slotType ||
86+
if (widget.slotType != oldWidget.slotType ||
9187
widget.adConfig != oldWidget.adConfig) {
9288
_logger.info(
9389
'InArticleAdLoaderWidget updated for new slot type: '
94-
'${widget.slotType.name} or contextKey: ${widget.contextKey}. '
95-
'Re-loading ad.',
90+
'${widget.slotType.name} or adConfig changed. Re-loading ad.',
9691
);
9792

98-
// Dispose of the old ad's resources before loading a new one to prevent
99-
// memory leaks if the widget is reused for a different context.
100-
_adCacheService.removeAndDisposeAd(
101-
contextKey: oldWidget.contextKey,
102-
placeholderId: oldWidget.slotType.name,
103-
);
93+
// Cancel the previous loading operation if it's still active and not yet
94+
// completed. This prevents a race condition if a new load is triggered.
10495
if (_loadAdCompleter != null && !_loadAdCompleter!.isCompleted) {
10596
// Complete normally to prevent crashes
10697
_loadAdCompleter!.complete();
10798
}
10899
_loadAdCompleter = null;
109100

110-
setState(() {
111-
_loadedAd = null;
112-
_isLoading = true;
113-
_hasError = false;
114-
});
101+
// If an ad was previously loaded, dispose of its resources
102+
// immediately as this widget is now responsible for its lifecycle.
103+
if (_loadedAd != null) {
104+
_logger.info(
105+
'Disposing old ad for slot "${oldWidget.slotType.name}" '
106+
'before loading new one.',
107+
);
108+
_adService.disposeAd(_loadedAd);
109+
}
110+
111+
// Immediately set the widget to a loading state to prevent UI flicker.
112+
// This ensures a smooth transition from the old ad (or no ad) to the
113+
// loading indicator for the new ad.
114+
if (mounted) {
115+
setState(() {
116+
_loadedAd = null;
117+
_isLoading = true;
118+
_hasError = false;
119+
});
120+
}
115121
_loadAd();
116122
}
117123
}
118124

119125
@override
120126
void dispose() {
121-
// The ad object (_loadedAd) is intentionally not disposed of here.
122-
// Its lifecycle is managed by the InlineAdCacheService. The ad remains in
123-
// the cache even after navigating away from the article, allowing for
124-
// instant reuse if the user returns. The cache is managed by time-based
125-
// staleness checks and is cleared globally on user logout.
127+
// The ad object (_loadedAd) is managed by this widget.
128+
// Therefore, its resources MUST be explicitly disposed of here
129+
// when the widget is removed from the widget tree to prevent
130+
// memory leaks.
131+
if (_loadedAd != null) {
132+
_logger.info(
133+
'Disposing in-article ad for slot "${widget.slotType.name}" '
134+
'as widget is being disposed.',
135+
);
136+
_adService.disposeAd(_loadedAd);
137+
}
138+
139+
// Cancel any pending ad loading operation when the widget is disposed.
140+
// This prevents `setState()` calls on a disposed widget.
126141
if (_loadAdCompleter != null && !_loadAdCompleter!.isCompleted) {
127142
// Complete normally to prevent crashes
128143
_loadAdCompleter!.complete();
@@ -133,63 +148,28 @@ class _InArticleAdLoaderWidgetState extends State<InArticleAdLoaderWidget> {
133148

134149
/// Loads the in-article ad for this slot.
135150
///
136-
/// This method first checks the [InlineAdCacheService] for a pre-loaded
137-
/// [InlineAd]. If found, it uses the cached ad. Otherwise, it requests a new
138-
/// in-article ad from the [AdService] using `getInArticleAd` and stores it
139-
/// in the cache upon success.
151+
/// This method directly requests a new in-article ad from the
152+
/// [AdService] using `getInArticleAd`. It stores the loaded ad in
153+
/// its local state (`_loadedAd`). This widget does not use an
154+
/// external cache for its ads; its lifecycle is entirely self-managed.
155+
///
156+
/// It also includes defensive checks (`mounted`) to prevent `setState` calls
157+
/// on disposed widgets and ensures the `_loadAdCompleter` is always
158+
/// completed to prevent `StateError`s.
140159
Future<void> _loadAd() async {
160+
// Initialize a new completer for this loading operation.
141161
_loadAdCompleter = Completer<void>();
142162

163+
// Ensure the widget is still mounted before proceeding.
164+
// This prevents the "setState() called after dispose()" error.
143165
if (!mounted) {
144166
if (_loadAdCompleter?.isCompleted == false) {
145167
_loadAdCompleter!.complete();
146168
}
147169
return;
148170
}
149171

150-
// Use the slotType as the placeholderId within the context of the article.
151-
final placeholderId = widget.slotType.name;
152-
153-
// TODO(fulleni): This should be sourced from RemoteConfig.
154-
const adMaxAge = Duration(minutes: 5);
155-
156-
var cachedAd = _adCacheService.getAd(
157-
contextKey: widget.contextKey,
158-
placeholderId: placeholderId,
159-
);
160-
161-
if (cachedAd != null) {
162-
if (DateTime.now().difference(cachedAd.createdAt) > adMaxAge) {
163-
_logger.info(
164-
'Cached in-article ad for context "${widget.contextKey}" and slot '
165-
'${widget.slotType.name} is stale. Discarding and fetching new.',
166-
);
167-
_adCacheService.removeAndDisposeAd(
168-
contextKey: widget.contextKey,
169-
placeholderId: placeholderId,
170-
);
171-
cachedAd = null;
172-
} else {
173-
_logger.info(
174-
'Using fresh cached in-article ad for context "${widget.contextKey}" '
175-
'and slot: ${widget.slotType.name}',
176-
);
177-
if (mounted) {
178-
setState(() {
179-
_loadedAd = cachedAd;
180-
_isLoading = false;
181-
});
182-
}
183-
if (_loadAdCompleter?.isCompleted == false)
184-
_loadAdCompleter!.complete();
185-
return;
186-
}
187-
}
188-
189-
_logger.info(
190-
'Loading new in-article ad for context "${widget.contextKey}" '
191-
'and slot: ${widget.slotType.name}',
192-
);
172+
_logger.info('Loading new in-article ad for slot: ${widget.slotType.name}');
193173
try {
194174
// Get the current user role from AppBloc
195175
final appBlocState = context.read<AppBloc>().state;
@@ -205,42 +185,35 @@ class _InArticleAdLoaderWidgetState extends State<InArticleAdLoaderWidget> {
205185

206186
if (loadedAd != null) {
207187
_logger.info(
208-
'New in-article ad loaded for context "${widget.contextKey}" '
209-
'and slot: ${widget.slotType.name}',
188+
'New in-article ad loaded for slot: ${widget.slotType.name}',
210189
);
211-
// Cache the newly loaded ad.
212-
_adCacheService.setAd(
213-
contextKey: widget.contextKey,
214-
placeholderId: placeholderId,
215-
ad: loadedAd,
216-
);
217-
218190
if (mounted) {
219191
setState(() {
220192
_loadedAd = loadedAd;
221193
_isLoading = false;
222194
});
223195
}
224-
if (_loadAdCompleter?.isCompleted == false)
196+
if (_loadAdCompleter?.isCompleted == false) {
225197
_loadAdCompleter!.complete();
198+
}
226199
} else {
227200
_logger.warning(
228-
'Failed to load in-article ad for context "${widget.contextKey}" '
229-
'and slot: ${widget.slotType.name}. No ad returned.',
201+
'Failed to load in-article ad for slot: ${widget.slotType.name}. '
202+
'No ad returned.',
230203
);
231204
if (mounted) {
232205
setState(() {
233206
_hasError = true;
234207
_isLoading = false;
235208
});
236209
}
237-
if (_loadAdCompleter?.isCompleted == false)
210+
if (_loadAdCompleter?.isCompleted == false) {
238211
_loadAdCompleter!.complete();
212+
}
239213
}
240214
} catch (e, s) {
241215
_logger.severe(
242-
'Error loading in-article ad for context "${widget.contextKey}" '
243-
'and slot: ${widget.slotType.name}: $e',
216+
'Error loading in-article ad for slot: ${widget.slotType.name}: $e',
244217
e,
245218
s,
246219
);
@@ -250,7 +223,9 @@ class _InArticleAdLoaderWidgetState extends State<InArticleAdLoaderWidget> {
250223
_isLoading = false;
251224
});
252225
}
253-
if (_loadAdCompleter?.isCompleted == false) _loadAdCompleter!.complete();
226+
if (_loadAdCompleter?.isCompleted == false) {
227+
_loadAdCompleter!.complete();
228+
}
254229
}
255230
}
256231

lib/ads/widgets/local_interstitial_ad_dialog.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class LocalInterstitialAdDialog extends StatefulWidget {
2727
}
2828

2929
class _LocalInterstitialAdDialogState extends State<LocalInterstitialAdDialog> {
30-
//TODO(fulleni): make teh countdown configurable throuigh teh remote config.
30+
//TODO(fulleni): make the countdown configurable throuigh teh remote config.
3131
static const int _countdownDuration = 5;
3232
int _countdown = _countdownDuration;
3333
Timer? _timer;

lib/app/bloc/app_bloc.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import 'package:data_repository/data_repository.dart';
66
import 'package:equatable/equatable.dart';
77
import 'package:flex_color_scheme/flex_color_scheme.dart';
88
import 'package:flutter/material.dart';
9-
import 'package:flutter_news_app_mobile_client_full_source_code/ads/inline_ad_cache_service.dart';
109
import 'package:flutter_bloc/flutter_bloc.dart';
10+
import 'package:flutter_news_app_mobile_client_full_source_code/ads/inline_ad_cache_service.dart';
1111
import 'package:flutter_news_app_mobile_client_full_source_code/app/models/app_life_cycle_status.dart';
1212
import 'package:flutter_news_app_mobile_client_full_source_code/app/models/initialization_result.dart';
1313
import 'package:flutter_news_app_mobile_client_full_source_code/app/services/app_initializer.dart';

0 commit comments

Comments
 (0)