Skip to content

Commit 1141bf4

Browse files
authored
Merge pull request #116 from flutter-news-app-full-source-code/enhance-teh-ad-interstitial-manager-ui-integration
Enhance teh ad interstitial manager UI integration
2 parents 02019fe + 0ca3c38 commit 1141bf4

21 files changed

+603
-635
lines changed

lib/account/view/manage_followed_items/countries/followed_countries_list_page.dart

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,20 @@ class FollowedCountriesListPage extends StatelessWidget {
9696
);
9797
},
9898
),
99-
onTap: () {
100-
context.read<InterstitialAdManager>().onPotentialAdTrigger(
101-
context: context,
102-
);
103-
context.pushNamed(
99+
onTap: () async {
100+
// Await for the ad to be shown and dismissed.
101+
await context
102+
.read<InterstitialAdManager>()
103+
.onPotentialAdTrigger();
104+
105+
// Check if the widget is still in the tree before navigating.
106+
if (!context.mounted) return;
107+
108+
// Proceed with navigation after the ad is closed.
109+
await context.pushNamed(
104110
Routes.entityDetailsName,
105111
pathParameters: {
106-
'type': ContentType.country.name, // 'topic'
112+
'type': ContentType.country.name,
107113
'id': country.id,
108114
},
109115
);

lib/account/view/manage_followed_items/sources/followed_sources_list_page.dart

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,17 @@ class FollowedSourcesListPage extends StatelessWidget {
9393
);
9494
},
9595
),
96-
onTap: () {
97-
context.read<InterstitialAdManager>().onPotentialAdTrigger(
98-
context: context,
99-
);
100-
context.pushNamed(
96+
onTap: () async {
97+
// Await for the ad to be shown and dismissed.
98+
await context
99+
.read<InterstitialAdManager>()
100+
.onPotentialAdTrigger();
101+
102+
// Check if the widget is still in the tree before navigating.
103+
if (!context.mounted) return;
104+
105+
// Proceed with navigation after the ad is closed.
106+
await context.pushNamed(
101107
Routes.entityDetailsName,
102108
pathParameters: {
103109
'type': ContentType.source.name,

lib/account/view/manage_followed_items/topics/followed_topics_list_page.dart

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,17 @@ class FollowedTopicsListPage extends StatelessWidget {
101101
);
102102
},
103103
),
104-
onTap: () {
105-
context.read<InterstitialAdManager>().onPotentialAdTrigger(
106-
context: context,
107-
);
108-
context.pushNamed(
104+
onTap: () async {
105+
// Await for the ad to be shown and dismissed.
106+
await context
107+
.read<InterstitialAdManager>()
108+
.onPotentialAdTrigger();
109+
110+
// Check if the widget is still in the tree before navigating.
111+
if (!context.mounted) return;
112+
113+
// Proceed with navigation after the ad is closed.
114+
await context.pushNamed(
109115
Routes.entityDetailsName,
110116
pathParameters: {
111117
'type': ContentType.topic.name,

lib/account/view/saved_headlines_page.dart

Lines changed: 18 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@ class SavedHeadlinesPage extends StatelessWidget {
7272
);
7373
}
7474

75+
Future<void> onHeadlineTap(Headline headline) async {
76+
// Await for the ad to be shown and dismissed.
77+
await context.read<InterstitialAdManager>().onPotentialAdTrigger();
78+
79+
// Check if the widget is still in the tree before navigating.
80+
if (!context.mounted) return;
81+
82+
// Proceed with navigation after the ad is closed.
83+
await context.pushNamed(
84+
Routes.accountArticleDetailsName,
85+
pathParameters: {'id': headline.id},
86+
extra: headline,
87+
);
88+
}
89+
7590
return ListView.separated(
7691
padding: const EdgeInsets.symmetric(
7792
vertical: AppSpacing.paddingSmall,
@@ -106,46 +121,19 @@ class SavedHeadlinesPage extends StatelessWidget {
106121
case HeadlineImageStyle.hidden:
107122
tile = HeadlineTileTextOnly(
108123
headline: headline,
109-
onHeadlineTap: () {
110-
context
111-
.read<InterstitialAdManager>()
112-
.onPotentialAdTrigger(context: context);
113-
context.goNamed(
114-
Routes.accountArticleDetailsName,
115-
pathParameters: {'id': headline.id},
116-
extra: headline,
117-
);
118-
},
124+
onHeadlineTap: () => onHeadlineTap(headline),
119125
trailing: trailingButton,
120126
);
121127
case HeadlineImageStyle.smallThumbnail:
122128
tile = HeadlineTileImageStart(
123129
headline: headline,
124-
onHeadlineTap: () {
125-
context
126-
.read<InterstitialAdManager>()
127-
.onPotentialAdTrigger(context: context);
128-
context.goNamed(
129-
Routes.accountArticleDetailsName,
130-
pathParameters: {'id': headline.id},
131-
extra: headline,
132-
);
133-
},
130+
onHeadlineTap: () => onHeadlineTap(headline),
134131
trailing: trailingButton,
135132
);
136133
case HeadlineImageStyle.largeThumbnail:
137134
tile = HeadlineTileImageTop(
138135
headline: headline,
139-
onHeadlineTap: () {
140-
context
141-
.read<InterstitialAdManager>()
142-
.onPotentialAdTrigger(context: context);
143-
context.goNamed(
144-
Routes.accountArticleDetailsName,
145-
pathParameters: {'id': headline.id},
146-
extra: headline,
147-
);
148-
},
136+
onHeadlineTap: () => onHeadlineTap(headline),
149137
trailing: trailingButton,
150138
);
151139
}

lib/ads/interstitial_ad_manager.dart

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,10 @@ class InterstitialAdManager {
153153
///
154154
/// This method increments the transition counter and shows a pre-loaded ad
155155
/// if the frequency criteria are met.
156-
Future<void> onPotentialAdTrigger({required BuildContext context}) async {
156+
///
157+
/// Returns a [Future] that completes when the ad is dismissed, allowing the
158+
/// caller to await the ad's lifecycle before proceeding with navigation.
159+
Future<void> onPotentialAdTrigger() async {
157160
_transitionCount++;
158161
_logger.info('Potential ad trigger. Transition count: $_transitionCount');
159162

@@ -169,7 +172,7 @@ class InterstitialAdManager {
169172

170173
if (requiredTransitions > 0 && _transitionCount >= requiredTransitions) {
171174
_logger.info('Transition count meets threshold. Attempting to show ad.');
172-
await _showAd(context);
175+
await _showAd();
173176
_transitionCount =
174177
0; // Reset counter after showing (or attempting to show)
175178
} else {
@@ -180,7 +183,9 @@ class InterstitialAdManager {
180183
}
181184

182185
/// Shows the pre-loaded interstitial ad.
183-
Future<void> _showAd(BuildContext context) async {
186+
///
187+
/// Returns a [Future] that completes when the ad is dismissed.
188+
Future<void> _showAd() async {
184189
if (_preloadedAd == null) {
185190
_logger.warning(
186191
'Show ad called, but no ad is pre-loaded. Pre-loading now.',
@@ -199,13 +204,28 @@ class InterstitialAdManager {
199204
try {
200205
switch (adToShow.provider) {
201206
case AdPlatformType.admob:
207+
// AdMob does not require context to be shown.
202208
await _showAdMobAd(adToShow);
203209
case AdPlatformType.local:
204-
// ignore: use_build_context_synchronously
205-
await _showLocalAd(context, adToShow);
210+
// Local ads require context. Get it just before use.
211+
final context = _appBloc.navigatorKey.currentContext;
212+
if (context != null && context.mounted) {
213+
await _showLocalAd(context, adToShow);
214+
} else {
215+
_logger.warning(
216+
'Cannot show local ad: context is null or no longer mounted.',
217+
);
218+
}
206219
case AdPlatformType.demo:
207-
// ignore: use_build_context_synchronously
208-
await _showDemoAd(context);
220+
// Demo ads require context. Get it just before use.
221+
final context = _appBloc.navigatorKey.currentContext;
222+
if (context != null && context.mounted) {
223+
await _showDemoAd(context);
224+
} else {
225+
_logger.warning(
226+
'Cannot show demo ad: context is null or no longer mounted.',
227+
);
228+
}
209229
}
210230
} catch (e, s) {
211231
_logger.severe('Error showing interstitial ad: $e', e, s);
@@ -219,35 +239,49 @@ class InterstitialAdManager {
219239

220240
Future<void> _showAdMobAd(InterstitialAd ad) async {
221241
if (ad.adObject is! admob.InterstitialAd) return;
242+
243+
final completer = Completer<void>();
222244
final admobAd = ad.adObject as admob.InterstitialAd
223245
..fullScreenContentCallback = admob.FullScreenContentCallback(
224246
onAdShowedFullScreenContent: (ad) =>
225247
_logger.info('AdMob ad showed full screen.'),
226248
onAdDismissedFullScreenContent: (ad) {
227249
_logger.info('AdMob ad dismissed.');
228250
ad.dispose();
251+
if (!completer.isCompleted) {
252+
completer.complete();
253+
}
229254
},
230255
onAdFailedToShowFullScreenContent: (ad, error) {
231256
_logger.severe('AdMob ad failed to show: $error');
232257
ad.dispose();
258+
if (!completer.isCompleted) {
259+
// Complete normally even on failure to unblock navigation.
260+
completer.complete();
261+
}
233262
},
234263
);
235264
await admobAd.show();
265+
return completer.future;
236266
}
237267

238268
Future<void> _showLocalAd(BuildContext context, InterstitialAd ad) async {
239269
if (ad.adObject is! LocalInterstitialAd) return;
270+
// Await the result of showDialog, which completes when the dialog is popped.
240271
await showDialog<void>(
241272
context: context,
273+
barrierDismissible: false, // Prevent dismissing by tapping outside
242274
builder: (_) => LocalInterstitialAdDialog(
243275
localInterstitialAd: ad.adObject as LocalInterstitialAd,
244276
),
245277
);
246278
}
247279

248280
Future<void> _showDemoAd(BuildContext context) async {
281+
// Await the result of showDialog, which completes when the dialog is popped.
249282
await showDialog<void>(
250283
context: context,
284+
barrierDismissible: false, // Prevent dismissing by tapping outside
251285
builder: (_) => const DemoInterstitialAdDialog(),
252286
);
253287
}

lib/ads/widgets/demo_interstitial_ad_dialog.dart

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:async';
2+
13
import 'package:flutter/material.dart';
24
import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart';
35
import 'package:ui_kit/ui_kit.dart';
@@ -6,15 +8,54 @@ import 'package:ui_kit/ui_kit.dart';
68
/// A dialog widget that displays a placeholder for an interstitial ad in demo mode.
79
///
810
/// This dialog mimics a full-screen interstitial ad but contains only static
9-
/// text to indicate it's a demo.
11+
/// text to indicate it's a demo. It includes a countdown before the close
12+
/// button is enabled.
1013
/// {@endtemplate}
11-
class DemoInterstitialAdDialog extends StatelessWidget {
14+
class DemoInterstitialAdDialog extends StatefulWidget {
1215
/// {@macro demo_interstitial_ad_dialog}
1316
const DemoInterstitialAdDialog({super.key});
1417

18+
@override
19+
State<DemoInterstitialAdDialog> createState() =>
20+
_DemoInterstitialAdDialogState();
21+
}
22+
23+
class _DemoInterstitialAdDialogState extends State<DemoInterstitialAdDialog> {
24+
//TODO(fulleni): make teh countdown configurable throuigh teh remote config.
25+
static const int _countdownDuration = 5;
26+
int _countdown = _countdownDuration;
27+
Timer? _timer;
28+
29+
@override
30+
void initState() {
31+
super.initState();
32+
_startTimer();
33+
}
34+
35+
void _startTimer() {
36+
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
37+
if (_countdown > 0) {
38+
setState(() {
39+
_countdown--;
40+
});
41+
} else {
42+
_timer?.cancel();
43+
}
44+
});
45+
}
46+
47+
@override
48+
void dispose() {
49+
_timer?.cancel();
50+
super.dispose();
51+
}
52+
1553
@override
1654
Widget build(BuildContext context) {
1755
final theme = Theme.of(context);
56+
final l10n = AppLocalizations.of(context);
57+
final canClose = _countdown == 0;
58+
1859
return Dialog.fullscreen(
1960
backgroundColor: theme.colorScheme.surface,
2061
child: Stack(
@@ -25,15 +66,15 @@ class DemoInterstitialAdDialog extends StatelessWidget {
2566
mainAxisAlignment: MainAxisAlignment.center,
2667
children: [
2768
Text(
28-
AppLocalizations.of(context).demoInterstitialAdText,
69+
l10n.demoInterstitialAdText,
2970
style: theme.textTheme.titleLarge?.copyWith(
3071
color: theme.colorScheme.onSurface,
3172
),
3273
textAlign: TextAlign.center,
3374
),
3475
const SizedBox(height: AppSpacing.md),
3576
Text(
36-
AppLocalizations.of(context).demoInterstitialAdDescription,
77+
l10n.demoInterstitialAdDescription,
3778
style: theme.textTheme.bodyMedium?.copyWith(
3879
color: theme.colorScheme.onSurfaceVariant,
3980
),
@@ -46,13 +87,38 @@ class DemoInterstitialAdDialog extends StatelessWidget {
4687
Positioned(
4788
top: AppSpacing.lg,
4889
right: AppSpacing.lg,
49-
child: IconButton(
50-
icon: Icon(Icons.close, color: theme.colorScheme.onSurface),
51-
onPressed: () {
52-
// Dismiss the dialog.
53-
Navigator.of(context).pop();
54-
},
55-
),
90+
child: canClose
91+
? IconButton(
92+
icon: Icon(Icons.close, color: theme.colorScheme.onSurface),
93+
onPressed: () => Navigator.of(context).pop(),
94+
)
95+
: Container(
96+
padding: const EdgeInsets.symmetric(
97+
horizontal: AppSpacing.sm,
98+
vertical: AppSpacing.xs,
99+
),
100+
decoration: BoxDecoration(
101+
color: theme.colorScheme.onSurface.withOpacity(0.1),
102+
borderRadius: BorderRadius.circular(AppSpacing.lg),
103+
),
104+
child: Row(
105+
mainAxisSize: MainAxisSize.min,
106+
children: [
107+
Text(
108+
'$_countdown',
109+
style: theme.textTheme.bodySmall?.copyWith(
110+
color: theme.colorScheme.onSurface,
111+
fontWeight: FontWeight.bold,
112+
),
113+
),
114+
const SizedBox(width: AppSpacing.xs),
115+
Icon(
116+
Icons.close,
117+
color: theme.colorScheme.onSurface.withOpacity(0.5),
118+
),
119+
],
120+
),
121+
),
56122
),
57123
],
58124
),

0 commit comments

Comments
 (0)