|
| 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