From 0c859d71426eaa5b46f934820e9775c00f18ee51 Mon Sep 17 00:00:00 2001 From: ssbg04 Date: Tue, 18 Aug 2026 15:35:47 +0800 Subject: [PATCH] feat(fcm): support firebase-admin v14 modular API and google-services integration --- .gitignore | 1 + backend/.example.env | 1 + backend/src/services/fcmService.js | 24 +++++-- frontend/android/app/google-services.json | 29 ++++++++ .../services/background_sync_service.dart | 8 ++- .../core/services/notification_service.dart | 7 +- .../domain/repositories/auth_repository.dart | 8 +++ .../ui/providers/notification_provider.dart | 67 ++++++++++++++----- .../lib/ui/screens/splash/splash_screen.dart | 2 + frontend/pubspec.lock | 56 ++++++++++++++++ .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 12 files changed, 182 insertions(+), 25 deletions(-) create mode 100644 frontend/android/app/google-services.json diff --git a/.gitignore b/.gitignore index b95f0e8..2506af2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ TIS* backend/.env backend/.env.* !backend/.example.env +backend/*firebase*.json backend/brain frontend/brain diff --git a/backend/.example.env b/backend/.example.env index aab0b59..891fa62 100644 --- a/backend/.example.env +++ b/backend/.example.env @@ -4,3 +4,4 @@ DB_PATH=./data/tis_rms.db UPLOAD_PATH=./uploads # Root folder where auto-created student directories are stored STUDENT_DIR_ROOT=./data/students +FIREBASE_SERVICE_ACCOUNT=./firebase-service-account.json \ No newline at end of file diff --git a/backend/src/services/fcmService.js b/backend/src/services/fcmService.js index 1bbd1f4..e307c15 100644 --- a/backend/src/services/fcmService.js +++ b/backend/src/services/fcmService.js @@ -1,5 +1,8 @@ 'use strict'; const path = require('path'); +const fs = require('fs'); +const { initializeApp, getApps, cert } = require('firebase-admin/app'); +const { getMessaging } = require('firebase-admin/messaging'); let messaging = null; let initialized = false; @@ -15,13 +18,22 @@ const init = () => { } try { - const admin = require('firebase-admin'); - if (!admin.apps.length) { - const serviceAccount = require(path.resolve(serviceAccountPath)); - admin.initializeApp({ credential: admin.credential.cert(serviceAccount) }); + const resolvedPath = path.isAbsolute(serviceAccountPath) + ? serviceAccountPath + : path.resolve(process.cwd(), serviceAccountPath); + + if (!fs.existsSync(resolvedPath)) { + console.error('[FCM] Service account file not found at:', resolvedPath); + return; } - messaging = admin.messaging(); - console.log('[FCM] Firebase Admin initialized'); + + const serviceAccount = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')); + + if (!getApps().length) { + initializeApp({ credential: cert(serviceAccount) }); + } + messaging = getMessaging(); + console.log('[FCM] Firebase Admin initialized successfully'); } catch (err) { console.error('[FCM] Init failed:', err.message); messaging = null; diff --git a/frontend/android/app/google-services.json b/frontend/android/app/google-services.json new file mode 100644 index 0000000..2a3a97e --- /dev/null +++ b/frontend/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "893519269983", + "project_id": "tis-rms-e05b9", + "storage_bucket": "tis-rms-e05b9.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:893519269983:android:dc7f51f83b890700390bea", + "android_client_info": { + "package_name": "com.teamsb.tis_rms" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyCrT0WQVyVYyELlw0qN0IT48R_aJMqQBKI" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/frontend/lib/core/services/background_sync_service.dart b/frontend/lib/core/services/background_sync_service.dart index e83147a..8525658 100644 --- a/frontend/lib/core/services/background_sync_service.dart +++ b/frontend/lib/core/services/background_sync_service.dart @@ -15,12 +15,16 @@ void callbackDispatcher() { // 1. Retrieve the saved base URL and Auth Token final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); String rawUrl = prefs.getString('server_url') ?? ApiConstants.baseUrl; final clean = rawUrl.replaceAll(RegExp(r'/+$'), ''); final baseUrl = clean.endsWith('/api') ? clean : '$clean/api'; - const storage = FlutterSecureStorage(); - final token = await storage.read(key: 'jwt_token'); + String? token = prefs.getString('jwt_token'); + if (token == null || token.isEmpty) { + const storage = FlutterSecureStorage(); + token = await storage.read(key: 'jwt_token'); + } if (token == null || token.isEmpty) { return Future.value(true); // Not logged in } diff --git a/frontend/lib/core/services/notification_service.dart b/frontend/lib/core/services/notification_service.dart index 215f501..65948a9 100644 --- a/frontend/lib/core/services/notification_service.dart +++ b/frontend/lib/core/services/notification_service.dart @@ -64,6 +64,8 @@ class NotificationService { 'Recent Activities', description: 'Notifications for recent activities and system events', importance: Importance.max, + playSound: true, + enableVibration: true, ); await androidImplementation?.createNotificationChannel(channel); } @@ -88,7 +90,10 @@ class NotificationService { channelDescription: 'Notifications for recent activities and system events', importance: Importance.max, - priority: Priority.high, + priority: Priority.max, + playSound: true, + enableVibration: true, + visibility: NotificationVisibility.public, showWhen: true, ); diff --git a/frontend/lib/domain/repositories/auth_repository.dart b/frontend/lib/domain/repositories/auth_repository.dart index 539f56f..3e98304 100644 --- a/frontend/lib/domain/repositories/auth_repository.dart +++ b/frontend/lib/domain/repositories/auth_repository.dart @@ -29,6 +29,8 @@ class AuthRepository { key: _rememberMeKey, value: rememberMe ? 'true' : 'false', ); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_tokenKey, token); final userData = response.data['user']; return UserModel( @@ -60,6 +62,9 @@ class AuthRepository { if (token == null) return null; try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_tokenKey, token); + final options = Options(headers: {'Authorization': 'Bearer $token'}); final response = await _dio.get('/auth/profile', options: options); return UserModel.fromJson(response.data); @@ -80,6 +85,9 @@ class AuthRepository { Future logout() async { await _storage.delete(key: _tokenKey); await _storage.delete(key: _rememberMeKey); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_tokenKey); + await prefs.remove(_rememberMeKey); } Future _getAuthOptions() async { diff --git a/frontend/lib/ui/providers/notification_provider.dart b/frontend/lib/ui/providers/notification_provider.dart index 0949bb3..68ef3cb 100644 --- a/frontend/lib/ui/providers/notification_provider.dart +++ b/frontend/lib/ui/providers/notification_provider.dart @@ -20,9 +20,9 @@ class NotificationNotifier extends AsyncNotifier> { @override FutureOr> build() async { - // Start real-time polling every 30 seconds + // Start real-time polling every 10 seconds while app is open _pollingTimer?.cancel(); - _pollingTimer = Timer.periodic(const Duration(seconds: 30), (_) { + _pollingTimer = Timer.periodic(const Duration(seconds: 10), (_) { refreshNotifications(); }); @@ -32,32 +32,67 @@ class NotificationNotifier extends AsyncNotifier> { }); final repo = ref.read(notificationRepositoryProvider); - return await repo.getNotifications(); - } + final list = await repo.getNotifications(); - Future refreshNotifications() async { - try { - final oldList = state.value ?? []; - final repo = ref.read(notificationRepositoryProvider); - final list = await repo.getNotifications(); + // Check if there are any unread notifications on initial load + if (list.isNotEmpty) { + final prefs = await SharedPreferences.getInstance(); + final highestOldId = prefs.getInt('last_seen_notification_id') ?? 0; - // Trigger native notification for newly fetched items - if (oldList.isNotEmpty && list.isNotEmpty) { - final highestOldId = oldList.map((e) => e.id).reduce(math.max); - final newNotes = list.where((e) => e.id > highestOldId).toList(); + final newNotes = list.where((e) { + if (!e.isRead) { + if (highestOldId == 0) return true; + return e.id > highestOldId; + } + return false; + }).toList(); + if (newNotes.isNotEmpty) { for (var note in newNotes) { - NotificationService().showNotification( + await NotificationService().showNotification( + id: note.id, title: note.title, body: note.message, ); } } + final highestId = list.map((e) => e.id).reduce(math.max); + await prefs.setInt('last_seen_notification_id', math.max(highestOldId, highestId)); + } + + return list; + } + + Future refreshNotifications() async { + try { + final repo = ref.read(notificationRepositoryProvider); + final list = await repo.getNotifications(); + + final prefs = await SharedPreferences.getInstance(); + final highestOldId = prefs.getInt('last_seen_notification_id') ?? 0; + if (list.isNotEmpty) { + final newNotes = list.where((e) { + if (!e.isRead) { + if (highestOldId == 0) return true; + return e.id > highestOldId; + } + return false; + }).toList(); + + if (newNotes.isNotEmpty) { + for (var note in newNotes) { + await NotificationService().showNotification( + id: note.id, + title: note.title, + body: note.message, + ); + } + } + final highestId = list.map((e) => e.id).reduce(math.max); - final prefs = await SharedPreferences.getInstance(); - await prefs.setInt('last_seen_notification_id', highestId); + await prefs.setInt('last_seen_notification_id', math.max(highestOldId, highestId)); } state = AsyncData(list); diff --git a/frontend/lib/ui/screens/splash/splash_screen.dart b/frontend/lib/ui/screens/splash/splash_screen.dart index 1b851b8..40a9d13 100644 --- a/frontend/lib/ui/screens/splash/splash_screen.dart +++ b/frontend/lib/ui/screens/splash/splash_screen.dart @@ -10,6 +10,7 @@ import '../login/login_screen.dart'; import '../../layouts/windows_sidebar_layout.dart'; import '../../layouts/android_bottom_nav_layout.dart'; import '../../providers/auth_provider.dart'; +import '../../../core/services/foreground_sync_service.dart'; class SplashScreen extends ConsumerStatefulWidget { const SplashScreen({super.key}); @@ -48,6 +49,7 @@ class _SplashScreenState extends ConsumerState } Future _initializeApp() async { + await ForegroundSyncService.requestPermissions(); await Future.delayed(const Duration(milliseconds: 800)); if (!mounted) return; diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 06030f8..8b85702 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 + url: "https://pub.dev" + source: hosted + version: "1.3.59" ansicolor: dependency: transitive description: @@ -329,6 +337,54 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+5" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" + url: "https://pub.dev" + source: hosted + version: "3.15.2" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" + url: "https://pub.dev" + source: hosted + version: "2.24.1" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc" + url: "https://pub.dev" + source: hosted + version: "15.2.10" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754" + url: "https://pub.dev" + source: hosted + version: "4.6.10" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390" + url: "https://pub.dev" + source: hosted + version: "3.10.10" fixnum: dependency: transitive description: diff --git a/frontend/windows/flutter/generated_plugin_registrant.cc b/frontend/windows/flutter/generated_plugin_registrant.cc index 3b371c7..aef20e4 100644 --- a/frontend/windows/flutter/generated_plugin_registrant.cc +++ b/frontend/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DesktopDropPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( diff --git a/frontend/windows/flutter/generated_plugins.cmake b/frontend/windows/flutter/generated_plugins.cmake index 1d7a116..66bcdcf 100644 --- a/frontend/windows/flutter/generated_plugins.cmake +++ b/frontend/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_drop file_selector_windows + firebase_core flutter_secure_storage_windows permission_handler_windows printing