Skip to content

Commit 9c848a5

Browse files
feat: daily challenge notification service (#1632)
* feat: daily challenge notification service * fix: replace rocket emoji * fix: reschedule notification to the next day if today's challenge is completed * test: add tests for daily challenge notification service * chore: minor clean up * fix: import order * fix: passDailyChallenge handling * fix: android compatibility * fix: display android notification --------- Co-authored-by: Niraj Nandish <[email protected]>
1 parent 3b4553e commit 9c848a5

File tree

9 files changed

+1465
-0
lines changed

9 files changed

+1465
-0
lines changed

mobile-app/android/app/src/main/AndroidManifest.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<uses-permission android:name="android.permission.WAKE_LOCK"/>
55
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
66
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
7+
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
78
<application android:label="freeCodeCamp"
89
android:icon="@mipmap/ic_launcher"
910
android:networkSecurityConfig="@xml/network_security_config">
@@ -41,6 +42,17 @@
4142
<action android:name="android.intent.action.MEDIA_BUTTON" />
4243
</intent-filter>
4344
</receiver>
45+
<receiver android:exported="false"
46+
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
47+
<receiver android:exported="false"
48+
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
49+
<intent-filter>
50+
<action android:name="android.intent.action.BOOT_COMPLETED"/>
51+
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
52+
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
53+
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
54+
</intent-filter>
55+
</receiver>
4456
<!-- Don't delete the meta-data below. This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
4557
<meta-data android:name="flutterEmbedding"
4658
android:value="2" />

mobile-app/lib/app/app.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:freecodecamp/service/learn/learn_file_service.dart';
77
import 'package:freecodecamp/service/learn/learn_offline_service.dart';
88
import 'package:freecodecamp/service/learn/learn_service.dart';
99
import 'package:freecodecamp/service/learn/daily_challenge_service.dart';
10+
import 'package:freecodecamp/service/learn/daily_challenge_notification_service.dart';
1011
import 'package:freecodecamp/service/locale_service.dart';
1112
import 'package:freecodecamp/service/navigation/quick_actions_service.dart';
1213
import 'package:freecodecamp/service/news/bookmark_service.dart';
@@ -70,6 +71,7 @@ import 'package:stacked_services/stacked_services.dart';
7071
LazySingleton(classType: DatabaseMigrationService),
7172
LazySingleton(classType: PodcastsDatabaseService),
7273
LazySingleton(classType: NotificationService),
74+
LazySingleton(classType: DailyChallengeNotificationService),
7375
LazySingleton(classType: DeveloperService),
7476
LazySingleton(classType: AuthenticationService),
7577
LazySingleton(classType: AppAudioService),

mobile-app/lib/app/app.locator.dart

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mobile-app/lib/main.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import 'package:freecodecamp/service/authentication/authentication_service.dart'
1414
import 'package:freecodecamp/service/dio_service.dart';
1515
import 'package:freecodecamp/service/firebase/analytics_service.dart';
1616
import 'package:freecodecamp/service/firebase/remote_config_service.dart';
17+
import 'package:freecodecamp/service/learn/daily_challenge_notification_service.dart';
1718
import 'package:freecodecamp/service/locale_service.dart';
1819
import 'package:freecodecamp/service/navigation/quick_actions_service.dart';
1920
import 'package:freecodecamp/service/news/api_service.dart';
@@ -50,6 +51,7 @@ Future<void> main({bool testing = false}) async {
5051
}
5152
await RemoteConfigService().init();
5253
await NotificationService().init();
54+
await DailyChallengeNotificationService().init();
5355

5456
runApp(const FreeCodeCampMobileApp());
5557

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
import 'dart:io';
2+
import 'dart:math';
3+
4+
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
5+
import 'package:freecodecamp/app/app.locator.dart';
6+
import 'package:freecodecamp/models/learn/completed_challenge_model.dart';
7+
import 'package:freecodecamp/models/main/user_model.dart';
8+
import 'package:freecodecamp/service/authentication/authentication_service.dart';
9+
import 'package:freecodecamp/service/learn/daily_challenge_service.dart';
10+
import 'package:shared_preferences/shared_preferences.dart';
11+
import 'package:timezone/data/latest.dart' as tz;
12+
import 'package:timezone/timezone.dart' as tz;
13+
14+
class DailyChallengeNotificationService {
15+
final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin;
16+
final DailyChallengeService _dailyChallengeService;
17+
final AuthenticationService _authenticationService;
18+
Random random = Random();
19+
20+
static const String _channelId = 'daily_challenge_channel';
21+
static const int _maxDays = 7;
22+
23+
DailyChallengeNotificationService({
24+
FlutterLocalNotificationsPlugin? flutterLocalNotificationsPlugin,
25+
DailyChallengeService? dailyChallengeService,
26+
AuthenticationService? authenticationService,
27+
}) : _flutterLocalNotificationsPlugin = flutterLocalNotificationsPlugin ??
28+
FlutterLocalNotificationsPlugin(),
29+
_dailyChallengeService =
30+
dailyChallengeService ?? locator<DailyChallengeService>(),
31+
_authenticationService =
32+
authenticationService ?? locator<AuthenticationService>();
33+
34+
Future<void> init() async {
35+
tz.initializeTimeZones();
36+
37+
const AndroidInitializationSettings androidInitializationSettings =
38+
AndroidInitializationSettings('@mipmap/launcher_icon');
39+
const DarwinInitializationSettings iosInitializationSettings =
40+
DarwinInitializationSettings();
41+
42+
const InitializationSettings initializationSettings =
43+
InitializationSettings(
44+
android: androidInitializationSettings,
45+
iOS: iosInitializationSettings,
46+
);
47+
48+
await _flutterLocalNotificationsPlugin.initialize(
49+
initializationSettings,
50+
onDidReceiveNotificationResponse: _onNotificationResponse,
51+
);
52+
53+
bool permissionGranted = false;
54+
if (Platform.isAndroid) {
55+
permissionGranted = await _flutterLocalNotificationsPlugin
56+
.resolvePlatformSpecificImplementation<
57+
AndroidFlutterLocalNotificationsPlugin>()
58+
?.requestNotificationsPermission() ??
59+
false;
60+
} else {
61+
// For iOS, we assume permission is granted after init
62+
permissionGranted = true;
63+
}
64+
65+
// Auto-enable daily notifications if system permission is granted
66+
// and user hasn't explicitly disabled them
67+
if (permissionGranted) {
68+
final prefs = await SharedPreferences.getInstance();
69+
final hasSetPreference =
70+
prefs.containsKey('daily_challenge_notifications_enabled');
71+
72+
if (!hasSetPreference) {
73+
// First time - enable notifications by default
74+
await enableDailyNotifications();
75+
} else if (await areNotificationsEnabled()) {
76+
// Re-schedule notifications if they were previously enabled
77+
await scheduleDailyChallengeNotification();
78+
}
79+
}
80+
}
81+
82+
void _onNotificationResponse(NotificationResponse response) {
83+
if (response.payload == 'daily_challenge_notification') {
84+
// Schedule the next notification
85+
scheduleDailyChallengeNotification();
86+
}
87+
}
88+
89+
Future<void> enableDailyNotifications() async {
90+
final prefs = await SharedPreferences.getInstance();
91+
await prefs.setBool('daily_challenge_notifications_enabled', true);
92+
93+
await scheduleDailyChallengeNotification();
94+
}
95+
96+
Future<void> disableDailyNotifications() async {
97+
await _flutterLocalNotificationsPlugin.cancelAll();
98+
final prefs = await SharedPreferences.getInstance();
99+
await prefs.setBool('daily_challenge_notifications_enabled', false);
100+
}
101+
102+
Future<bool> areNotificationsEnabled() async {
103+
final prefs = await SharedPreferences.getInstance();
104+
return prefs.getBool('daily_challenge_notifications_enabled') ?? false;
105+
}
106+
107+
Future<void> cancelAllNotifications() async {
108+
await _flutterLocalNotificationsPlugin.cancelAll();
109+
}
110+
111+
Future<bool> areSystemNotificationsEnabled() async {
112+
if (Platform.isAndroid) {
113+
final androidImplementation = _flutterLocalNotificationsPlugin
114+
.resolvePlatformSpecificImplementation<
115+
AndroidFlutterLocalNotificationsPlugin>();
116+
return await androidImplementation?.areNotificationsEnabled() ?? false;
117+
}
118+
119+
// iOS does not let apps check notification status after permission is granted, so we always return true.
120+
// If campers disable notifications in iOS Settings, scheduled notifications will not be shown.
121+
return true;
122+
}
123+
124+
// This method orchestrates the scheduling of daily challenge notifications for the user.
125+
// It ensures notifications are only scheduled if both user and system settings allow, and the notification window is still valid.
126+
// The method determines the appropriate start date based on user progress and cached data, updates the cache,
127+
// and schedules notifications for the next 7 days. If any prerequisite is not met, it exits early without scheduling.
128+
Future<void> scheduleDailyChallengeNotification() async {
129+
if (!(await areNotificationsEnabled()) ||
130+
!(await areSystemNotificationsEnabled())) {
131+
return;
132+
}
133+
134+
// Cancel any previous scheduled notifications
135+
await _flutterLocalNotificationsPlugin.cancelAll();
136+
137+
final prefs = await SharedPreferences.getInstance();
138+
final now = DateTime.now();
139+
140+
// Check if notification window has expired
141+
if (await hasNotificationWindowExpired(prefs, now)) {
142+
return;
143+
}
144+
145+
// Determine when to start scheduling notifications
146+
final startSchedulingFrom = await determineSchedulingStartDate(prefs, now);
147+
await prefs.setString('notification_schedule_start_date',
148+
startSchedulingFrom.toIso8601String());
149+
150+
// Schedule notifications for the next 7 days
151+
await scheduleNotificationsForPeriod(startSchedulingFrom, now);
152+
}
153+
154+
Future<bool> hasNotificationWindowExpired(
155+
SharedPreferences prefs, DateTime now) async {
156+
final scheduleStartDateStr =
157+
prefs.getString('notification_schedule_start_date');
158+
159+
if (scheduleStartDateStr != null) {
160+
final scheduleStartDate = DateTime.parse(scheduleStartDateStr);
161+
final daysSinceStart = now.difference(scheduleStartDate).inDays;
162+
163+
// If more than 7 days have passed and user hasn't opened the app,
164+
// we assume the user has stopped engaging and stop sending them notifications.
165+
return daysSinceStart >= _maxDays;
166+
}
167+
168+
return false;
169+
}
170+
171+
// This method determines the correct start date for scheduling notifications.
172+
// If the user has completed today's challenge, returns tomorrow's date.
173+
// Otherwise, returns the cached start date if available, or today if not.
174+
Future<DateTime> determineSchedulingStartDate(
175+
SharedPreferences prefs, DateTime now) async {
176+
final scheduleStartDateStr =
177+
prefs.getString('notification_schedule_start_date');
178+
179+
final todayChallengeCompleted = await checkIfTodayChallengeCompleted();
180+
DateTime startSchedulingFrom;
181+
182+
if (todayChallengeCompleted) {
183+
// If today's challenge is completed, start from tomorrow
184+
final tomorrow = now.add(const Duration(days: 1));
185+
186+
startSchedulingFrom =
187+
DateTime(tomorrow.year, tomorrow.month, tomorrow.day);
188+
} else {
189+
// If not completed, use cache if available, else today
190+
if (scheduleStartDateStr != null) {
191+
startSchedulingFrom = DateTime.parse(scheduleStartDateStr);
192+
} else {
193+
startSchedulingFrom = DateTime(now.year, now.month, now.day);
194+
}
195+
}
196+
197+
return startSchedulingFrom;
198+
}
199+
200+
Future<bool> checkIfTodayChallengeCompleted() async {
201+
try {
202+
final todayChallenge = await _dailyChallengeService.fetchTodayChallenge();
203+
final userModelFuture = _authenticationService.userModel;
204+
205+
if (userModelFuture != null) {
206+
try {
207+
FccUserModel user = await userModelFuture;
208+
for (CompletedDailyChallenge challenge
209+
in user.completedDailyCodingChallenges) {
210+
if (challenge.id == todayChallenge.id) {
211+
return true;
212+
}
213+
}
214+
} catch (e) {
215+
return false;
216+
}
217+
}
218+
return false;
219+
} catch (e) {
220+
// If we can't fetch today's challenge, assume it's not completed
221+
return false;
222+
}
223+
}
224+
225+
// This method schedules daily notifications for up to 7 days starting from the given date.
226+
// For each day in the period, this method calculates the appropriate notification time and schedules a notification if the time is in the future.
227+
// Notifications are set for 9 AM local time, and only future notifications are scheduled.
228+
Future<void> scheduleNotificationsForPeriod(
229+
DateTime startSchedulingFrom, DateTime now) async {
230+
final centralLocation = tz.getLocation('America/Chicago');
231+
final endDate = startSchedulingFrom.add(const Duration(days: 7));
232+
int notificationId = 0;
233+
234+
for (DateTime date = startSchedulingFrom;
235+
date.isBefore(endDate);
236+
date = date.add(const Duration(days: 1))) {
237+
final scheduleTime = findNextValidNotificationTime(date, centralLocation);
238+
239+
// Only schedule if the notification time is in the future
240+
if (scheduleTime.isAfter(now)) {
241+
try {
242+
await _flutterLocalNotificationsPlugin.zonedSchedule(
243+
notificationId++,
244+
'New Daily Challenge Available! 🧩',
245+
'A fresh coding challenge is waiting for you. Ready to solve it?',
246+
tz.TZDateTime.from(scheduleTime, tz.local),
247+
const NotificationDetails(
248+
android: AndroidNotificationDetails(
249+
_channelId,
250+
'Daily Challenge Notifications',
251+
channelDescription:
252+
'Notifications for new daily coding challenges',
253+
priority: Priority.high,
254+
importance: Importance.max,
255+
),
256+
iOS: DarwinNotificationDetails(
257+
threadIdentifier: 'daily-challenge-notification',
258+
categoryIdentifier: 'DAILY_CHALLENGE',
259+
),
260+
),
261+
androidScheduleMode: AndroidScheduleMode.inexact,
262+
payload: 'daily_challenge_notification',
263+
);
264+
} catch (e) {
265+
// Log the error but continue scheduling other notifications
266+
print('Failed to schedule notification for $scheduleTime: $e');
267+
}
268+
}
269+
}
270+
}
271+
272+
DateTime findNextValidNotificationTime(
273+
DateTime scheduleDate, tz.Location centralLocation,
274+
{DateTime? now}) {
275+
final current = now ?? DateTime.now();
276+
277+
// Default to 9 AM on the scheduleDate. This is used if scheduleDate is not today
278+
DateTime candidate =
279+
DateTime(scheduleDate.year, scheduleDate.month, scheduleDate.day, 9);
280+
281+
// If scheduleDate is today, adjust based on current time
282+
if (scheduleDate.day == current.day &&
283+
scheduleDate.month == current.month &&
284+
scheduleDate.year == current.year) {
285+
if (current.hour < 9) {
286+
// Before 9 AM today, schedule for 9 AM today
287+
candidate = DateTime(current.year, current.month, current.day, 9);
288+
} else if (current.hour >= 21) {
289+
// After 9 PM today, schedule for 9 AM tomorrow
290+
candidate = DateTime(current.year, current.month, current.day + 1, 9);
291+
} else {
292+
// Between 9 AM and 9 PM, schedule for 9 AM tomorrow
293+
candidate = DateTime(current.year, current.month, current.day + 1, 9);
294+
}
295+
}
296+
297+
return candidate;
298+
}
299+
}

0 commit comments

Comments
 (0)