diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index b722931..23687e8 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -171,6 +171,9 @@ Files backup/restore - mirror mode or zip --> + + --- # TIS_RMS Server Manager (dont do) diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml index 33783fc..bf17c14 100644 --- a/frontend/android/app/src/main/AndroidManifest.xml +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -10,16 +10,23 @@ android:required="false"/> - + + + + + + >(); if (list.isNotEmpty) { // 3. Find the last seen notification ID final highestOldId = prefs.getInt('last_seen_notification_id') ?? 0; - // Find new ones - final newNotes = list - .where((e) => (e['id'] as int) > highestOldId) - .toList(); + // Find new unread notifications + final newNotes = list.where((e) { + final id = e['id'] as int? ?? 0; + final isRead = (e['is_read'] == 1 || e['is_read'] == true); + if (highestOldId == 0) { + return !isRead; + } + return id > highestOldId && !isRead; + }).toList(); if (newNotes.isNotEmpty) { final notificationService = NotificationService(); - await notificationService - .initialize(); // Ensure it's ready in this isolate + await notificationService.initialize(); // 4. Show local notification for each new one for (var note in newNotes) { + final id = note['id'] as int?; + final title = note['title']?.toString() ?? 'TIS RMS Notification'; + final message = note['message']?.toString() ?? ''; await notificationService.showNotification( - title: note['title'] ?? 'New Notification', - body: note['message'] ?? '', + id: id, + title: title, + body: message, ); } + } - // 5. Update highest seen ID in prefs - final newHighestId = list.map((e) => e['id'] as int).reduce(math.max); + // 5. Update highest seen ID in prefs + final ids = list.map((e) => e['id'] as int? ?? 0).toList(); + if (ids.isNotEmpty) { + final newHighestId = ids.reduce(math.max); await prefs.setInt( 'last_seen_notification_id', math.max(highestOldId, newHighestId), @@ -63,8 +81,7 @@ void callbackDispatcher() { } return Future.value(true); - } catch (e) { - print('Background fetch failed: $e'); + } catch (_) { return Future.value(false); // Retries on false } }); diff --git a/frontend/lib/core/services/foreground_sync_service.dart b/frontend/lib/core/services/foreground_sync_service.dart new file mode 100644 index 0000000..e70bc9f --- /dev/null +++ b/frontend/lib/core/services/foreground_sync_service.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'dart:io' show Platform; +import 'dart:math' as math; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../network/api_constants.dart'; +import 'notification_service.dart'; + +@pragma('vm:entry-point') +void startForegroundCallback() { + FlutterForegroundTask.setTaskHandler(ForegroundSyncHandler()); +} + +class ForegroundSyncHandler extends TaskHandler { + final FlutterSecureStorage _storage = const FlutterSecureStorage(); + final NotificationService _notificationService = NotificationService(); + + @override + Future onStart(DateTime timestamp, TaskStarter starter) async { + await _notificationService.initialize(); + } + + @override + Future onRepeatEvent(DateTime timestamp) async { + try { + final prefs = await SharedPreferences.getInstance(); + String rawUrl = prefs.getString('server_url') ?? ApiConstants.baseUrl; + final clean = rawUrl.replaceAll(RegExp(r'/+$'), ''); + final baseUrl = clean.endsWith('/api') ? clean : '$clean/api'; + + final token = await _storage.read(key: 'jwt_token'); + if (token == null || token.isEmpty) { + return; + } + + final dio = Dio( + BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 5), + headers: {'Authorization': 'Bearer $token'}, + ), + ); + + final response = await dio.get('/notifications'); + final list = (response.data as List).cast>(); + + if (list.isNotEmpty) { + final highestOldId = prefs.getInt('last_seen_notification_id') ?? 0; + + final newNotes = list.where((e) { + final id = e['id'] as int? ?? 0; + final isRead = (e['is_read'] == 1 || e['is_read'] == true); + if (highestOldId == 0) return !isRead; + return id > highestOldId && !isRead; + }).toList(); + + if (newNotes.isNotEmpty) { + await _notificationService.initialize(); + for (var note in newNotes) { + final id = note['id'] as int?; + final title = note['title']?.toString() ?? 'TIS RMS Notification'; + final message = note['message']?.toString() ?? ''; + await _notificationService.showNotification( + id: id, + title: title, + body: message, + ); + } + } + + final ids = list.map((e) => e['id'] as int? ?? 0).toList(); + if (ids.isNotEmpty) { + final newHighestId = ids.reduce(math.max); + await prefs.setInt( + 'last_seen_notification_id', + math.max(highestOldId, newHighestId), + ); + } + } + } catch (_) { + // Ignore transient network errors + } + } + + @override + Future onDestroy(DateTime timestamp) async {} + + @override + void onReceiveData(Object data) {} + + @override + void onNotificationButtonPressed(String id) {} + + @override + void onNotificationPressed() { + FlutterForegroundTask.launchApp('/'); + } + + @override + void onNotificationDismissed() {} +} + +class ForegroundSyncService { + static Future init() async { + if (kIsWeb || !Platform.isAndroid) return; + + FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'tis_rms_foreground_sync', + channelName: 'TIS RMS Background Sync', + channelDescription: + 'Maintains real-time connection with local server for notifications', + channelImportance: NotificationChannelImportance.LOW, + priority: NotificationPriority.LOW, + ), + iosNotificationOptions: const IOSNotificationOptions( + showNotification: false, + playSound: false, + ), + foregroundTaskOptions: ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.repeat(10000), // Check every 10 seconds + autoRunOnBoot: true, + autoRunOnMyPackageReplaced: true, + allowWakeLock: true, + allowWifiLock: true, + ), + ); + } + + static Future start() async { + if (kIsWeb || !Platform.isAndroid) return; + + if (await FlutterForegroundTask.isRunningService) { + return; + } + + await FlutterForegroundTask.startService( + serviceId: 256, + notificationTitle: 'TIS RMS Sync Active', + notificationText: 'Connected to local server for real-time notifications', + notificationIcon: null, + callback: startForegroundCallback, + ); + } + + static Future stop() async { + if (kIsWeb || !Platform.isAndroid) return; + + if (await FlutterForegroundTask.isRunningService) { + await FlutterForegroundTask.stopService(); + } + } +} diff --git a/frontend/lib/core/services/notification_service.dart b/frontend/lib/core/services/notification_service.dart index 0d780cc..215f501 100644 --- a/frontend/lib/core/services/notification_service.dart +++ b/frontend/lib/core/services/notification_service.dart @@ -58,12 +58,21 @@ class NotificationService { AndroidFlutterLocalNotificationsPlugin >(); await androidImplementation?.requestNotificationsPermission(); + + const AndroidNotificationChannel channel = AndroidNotificationChannel( + 'tis_rms_activities_channel', + 'Recent Activities', + description: 'Notifications for recent activities and system events', + importance: Importance.max, + ); + await androidImplementation?.createNotificationChannel(channel); } _initialized = true; } Future showNotification({ + int? id, required String title, required String body, }) async { @@ -87,9 +96,11 @@ class NotificationService { android: androidPlatformChannelSpecifics, ); + final notificationId = + id ?? (DateTime.now().millisecondsSinceEpoch.remainder(100000)); + await _flutterLocalNotificationsPlugin.show( - DateTime.now() - .millisecond, // use millisecond as unique ID for multiple notifications + notificationId, title, body, platformChannelSpecifics, diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 9213b0b..a0883cc 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -13,6 +13,7 @@ import 'core/theme/app_theme.dart'; // Add this import import 'core/constants/app_colors.dart'; import 'core/services/notification_service.dart'; import 'core/services/background_sync_service.dart'; +import 'core/services/foreground_sync_service.dart'; import 'ui/providers/theme_provider.dart'; import 'ui/shared/widgets/inactivity_wrapper.dart'; import 'package:workmanager/workmanager.dart'; @@ -67,15 +68,17 @@ void main() async { } await NotificationService().initialize(); + await ForegroundSyncService.init(); if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { - Workmanager().initialize(callbackDispatcher, isInDebugMode: false); - // Register periodic task + Workmanager().initialize(callbackDispatcher); + // Register periodic background sync task Workmanager().registerPeriodicTask( - "1", + "tis_rms_background_sync", "background_sync_task", frequency: const Duration(minutes: 15), constraints: Constraints(networkType: NetworkType.connected), + existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, ); } diff --git a/frontend/lib/ui/providers/auth_provider.dart b/frontend/lib/ui/providers/auth_provider.dart index f08ac48..f5d5e31 100644 --- a/frontend/lib/ui/providers/auth_provider.dart +++ b/frontend/lib/ui/providers/auth_provider.dart @@ -8,6 +8,7 @@ import 'student_provider.dart' hide academicYearsProvider; import 'navigation_provider.dart'; import 'reports_provider.dart'; import 'connected_users_provider.dart'; +import '../../core/services/foreground_sync_service.dart'; final authRepositoryProvider = Provider( (ref) => AuthRepository(), @@ -38,6 +39,7 @@ class AuthNotifier extends AsyncNotifier { ); state = AsyncData(user); ref.read(heartbeatServiceProvider).start(user); + ForegroundSyncService.start(); return true; } catch (e, st) { state = AsyncError(e, st); @@ -53,6 +55,7 @@ class AuthNotifier extends AsyncNotifier { if (user != null) { state = AsyncData(user); ref.read(heartbeatServiceProvider).start(user); + ForegroundSyncService.start(); } return user; } catch (_) { @@ -62,6 +65,7 @@ class AuthNotifier extends AsyncNotifier { Future logout() async { ref.read(heartbeatServiceProvider).stop(); + ForegroundSyncService.stop(); final repository = ref.read(authRepositoryProvider); await repository.logout(); state = const AsyncData(null); diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 228807c..6abbbf6 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -350,6 +350,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_foreground_task: + dependency: "direct main" + description: + name: flutter_foreground_task + sha256: "206017ee1bf864f34b8d7bce664a172717caa21af8da23f55866470dfe316644" + url: "https://pub.dev" + source: hosted + version: "8.17.0" flutter_holo_date_picker: dependency: "direct main" description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 50a1edc..fd414d7 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -62,6 +62,7 @@ dependencies: sheetifye: ^1.1.0 flutter_local_notifications: ^17.2.2 workmanager: ^0.9.0+3 + flutter_foreground_task: ^8.17.0 desktop_drop: ^0.7.1 dev_dependencies: