import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:audioplayers/audioplayers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdfrx/pdfrx.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'firebase_options.dart'; import 'providers/profile_provider.dart'; import 'models/profile.dart'; import 'app.dart'; import 'providers/notifications_provider.dart'; import 'utils/app_time.dart'; import 'utils/notification_permission.dart'; import 'services/notification_service.dart'; import 'services/notification_bridge.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; final AudioPlayer _player = AudioPlayer(); Map _formatNotificationFromData(Map data) { final actor = (data['actor_name'] ?? data['mentioner_name'] ?? data['user_name'] ?? data['from'] ?? 'Someone') .toString(); final taskId = data['task_id'] ?? data['taskId'] ?? data['task'] ?? ''; final ticketId = data['ticket_id'] ?? data['ticketId'] ?? data['ticket'] ?? ''; final type = (data['type'] ?? '').toString().toLowerCase(); if (taskId.isNotEmpty && (type.contains('assign') || data['action'] == 'assign' || data['assigned'] == 'true')) { return { 'title': 'Task assigned', 'body': '$actor has assigned you a Task #$taskId', }; } if (taskId.isNotEmpty && (type.contains('mention') || data['action'] == 'mention' || data['mentioned'] == 'true')) { return { 'title': 'Mention', 'body': '$actor has mentioned you in Task #$taskId', }; } if (ticketId.isNotEmpty && (type.contains('mention') || data['action'] == 'mention')) { return { 'title': 'Mention', 'body': '$actor has mentioned you in Ticket #$ticketId', }; } // Fallback to supplied title/body or generic final title = data['title']?.toString() ?? 'New Notification'; final body = data['body']?.toString() ?? 'You have a new update in TasQ.'; return {'title': title, 'body': body}; } // Initialize the plugin final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); /// Handle messages received while the app is terminated or in background. @pragma('vm:entry-point') // Required for background execution Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { // 1. Initialize the plugin inside the background isolate final FlutterLocalNotificationsPlugin localNotifPlugin = FlutterLocalNotificationsPlugin(); // 2. Extract and format title/body from the DATA payload (not message.notification) final formatted = _formatNotificationFromData(message.data); final String title = formatted['title']!; final String body = formatted['body']!; // Determine a stable ID for deduplication (prefer server-provided id) final String stableId = message.data['notification_id'] ?? message.messageId ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); // Dedupe: keep a short-lived cache of recent notification IDs to avoid duplicates try { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString('recent_notifs') ?? '{}'; final Map recent = jsonDecode(raw); final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; const int ttl = 60; // seconds // prune old entries recent.removeWhere( (k, v) => (v is int ? v : int.parse(v.toString())) < now - ttl, ); if (recent.containsKey(stableId)) { // already shown recently — skip return; } recent[stableId] = now; await prefs.setString('recent_notifs', jsonEncode(recent)); } catch (e) { // If prefs fail in background isolate, fall back to showing notification } // Create a unique ID for the notification display final int id = DateTime.now().millisecondsSinceEpoch ~/ 1000; // 3. Define the exact same channel specifics const androidDetails = AndroidNotificationDetails( 'tasq_custom_sound_channel_3', 'High Importance Notifications', importance: Importance.max, priority: Priority.high, playSound: true, sound: RawResourceAndroidNotificationSound('tasq_notification'), ); // 4. Show the notification manually await localNotifPlugin.show( id: id, title: title, body: body, notificationDetails: const NotificationDetails(android: androidDetails), payload: message.data['type'], // Or whatever payload you need for routing ); } Future main() async { WidgetsFlutterBinding.ensureInitialized(); // The flag optionally hides annoying WASM warnings in your Chrome dev console pdfrxFlutterInitialize(dismissPdfiumWasmWarnings: true); // initialize Firebase before anything that uses messaging await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); await dotenv.load(fileName: '.env'); AppTime.initialize(location: 'Asia/Manila'); final supabaseUrl = dotenv.env['SUPABASE_URL'] ?? ''; final supabaseAnonKey = dotenv.env['SUPABASE_ANON_KEY'] ?? ''; if (supabaseUrl.isEmpty || supabaseAnonKey.isEmpty) { runApp(const _MissingConfigApp()); return; } await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey); // ensure token saved shortly after startup if already signed in. // Run this after runApp so startup is not blocked by network/token ops. final supaClient = Supabase.instance.client; // listen for auth changes to register/unregister token accordingly supaClient.auth.onAuthStateChange.listen((data) async { final event = data.event; if (kIsWeb) { debugPrint( 'auth state change $event on web: skipping FCM token handling', ); return; } String? token; try { token = await FirebaseMessaging.instance.getToken(); } catch (e) { debugPrint('FCM getToken failed during auth change: $e'); token = null; } debugPrint('auth state change $event, token=$token'); if (token == null) return; final ctrl = NotificationsController(supaClient); if (event == AuthChangeEvent.signedIn) { await ctrl.registerFcmToken(token); } else if (event == AuthChangeEvent.signedOut) { await ctrl.unregisterFcmToken(token); } }); if (!kIsWeb) { // on Android 13+ we must request POST_NOTIFICATIONS at runtime; without it // notifications are automatically denied and cannot be re‑enabled from the // system settings. The helper uses `permission_handler`. final granted = await ensureNotificationPermission(); if (!granted) { // we don’t block startup, but it’s worth logging so developers notice. // A real app might show a dialog pointing the user to settings. // debugPrint('notification permission not granted'); } // request FCM permission (iOS/Android13+) and handle foreground messages await FirebaseMessaging.instance.requestPermission(); } FirebaseMessaging.onMessage.listen((RemoteMessage message) async { // Prefer the data payload and format friendly messages, with dedupe. final formatted = _formatNotificationFromData(message.data); final String stableId = message.data['notification_id'] ?? message.messageId ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); try { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString('recent_notifs') ?? '{}'; final Map recent = jsonDecode(raw); final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; const int ttl = 60; recent.removeWhere( (k, v) => (v is int ? v : int.parse(v.toString())) < now - ttl, ); if (!recent.containsKey(stableId)) { recent[stableId] = now; await prefs.setString('recent_notifs', jsonEncode(recent)); NotificationService.show( id: DateTime.now().millisecondsSinceEpoch ~/ 1000, title: formatted['title']!, body: formatted['body']!, payload: message.data['payload'], ); } } catch (e) { // On failure, just show the notification to avoid dropping alerts NotificationService.show( id: DateTime.now().millisecondsSinceEpoch ~/ 1000, title: formatted['title']!, body: formatted['body']!, payload: message.data['payload'], ); } }); // initialize the local notifications plugin so we can post alerts later await NotificationService.initialize( onDidReceiveNotificationResponse: (response) { // handle user tapping a notification; the payload format is up to us final payload = response.payload; if (payload != null && payload.startsWith('ticket:')) { // ignore if context not mounted; we might use a navigator key in real // app, but keep this simple for now // TODO: navigate to ticket/task as appropriate } }, ); // 1. Define the High Importance Channel (This MUST match your manifest exactly) const AndroidNotificationChannel channel = AndroidNotificationChannel( 'tasq_custom_sound_channel', // id 'High Importance Notifications', // title visible to user in phone settings description: 'This channel is used for important TasQ notifications.', importance: Importance.max, // THIS is what forces the sound and heads-up banner playSound: true, // 👇 Tell Android to use your specific file in the raw folder sound: RawResourceAndroidNotificationSound('tasq_notification'), ); // 2. Create the channel on the device await flutterLocalNotificationsPlugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >() ?.createNotificationChannel(channel); // global navigator key used for snackbars/navigation from notification final navigatorKey = GlobalKey(); runApp( ProviderScope( observers: [NotificationSoundObserver()], child: NotificationBridge( navigatorKey: navigatorKey, child: const TasqApp(), ), ), ); // Post-startup: register current FCM token without blocking UI. if (!kIsWeb) { Future.microtask(() async { try { final token = await FirebaseMessaging.instance.getToken().timeout( const Duration(seconds: 10), ); if (token != null && supaClient.auth.currentUser != null) { debugPrint('post-startup registering FCM token: $token'); final ctrl = NotificationsController(supaClient); await ctrl.registerFcmToken(token); } } catch (e) { debugPrint('post-startup FCM token registration failed: $e'); } }); } } class NotificationSoundObserver extends ProviderObserver { StreamSubscription? _tokenSub; @override void didUpdateProvider( ProviderBase provider, Object? previousValue, Object? newValue, ProviderContainer container, ) { // play sound + show OS notification on unread-count increase if (provider == unreadNotificationsCountProvider) { final prev = previousValue as int?; final next = newValue as int?; if (prev != null && next != null && next > prev) { _maybeShowUnreadNotification(next); } } // when profile changes, register or unregister tokens if (provider == currentProfileProvider) { final profile = newValue as Profile?; final controller = container.read(notificationsControllerProvider); if (profile != null) { // signed in: save current token and keep listening for refreshes if (!kIsWeb) { FirebaseMessaging.instance .getToken() .then((token) { if (token != null) { debugPrint('profile observer registering token: $token'); controller.registerFcmToken(token); } }) .catchError((e) { debugPrint('getToken error: $e'); return null; }); _tokenSub = FirebaseMessaging.instance.onTokenRefresh.listen((token) { debugPrint('token refreshed: $token'); controller.registerFcmToken(token); }); } } else { // signed out: unregister whatever token we currently have _tokenSub?.cancel(); if (!kIsWeb) { FirebaseMessaging.instance .getToken() .then((token) { if (token != null) { debugPrint('profile observer unregistering token: $token'); controller.unregisterFcmToken(token); } }) .catchError((e) { debugPrint('getToken error: $e'); return null; }); } } } } } void _maybeShowUnreadNotification(int next) async { try { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString('recent_notifs') ?? '{}'; final Map recent = jsonDecode(raw); final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; const int ttl = 60; // seconds // prune old entries recent.removeWhere( (k, v) => (v is int ? v : int.parse(v.toString())) < now - ttl, ); // if there is any recent notification (from server or other), skip duplicating if (recent.isNotEmpty) return; // mark a synthetic unread-notif to prevent immediate duplicates recent['unread_summary'] = now; await prefs.setString('recent_notifs', jsonEncode(recent)); _player.play(AssetSource('tasq_notification.wav')); NotificationService.show( id: DateTime.now().millisecondsSinceEpoch ~/ 1000, title: 'New notifications', body: 'You have $next unread notifications.', ); } catch (e) { // fallback: show notification _player.play(AssetSource('tasq_notification.wav')); NotificationService.show( id: DateTime.now().millisecondsSinceEpoch ~/ 1000, title: 'New notifications', body: 'You have $next unread notifications.', ); } } class _MissingConfigApp extends StatelessWidget { const _MissingConfigApp(); @override Widget build(BuildContext context) { return const MaterialApp( home: Scaffold( body: Center( child: Padding( padding: EdgeInsets.all(24), child: Text( 'Missing SUPABASE_URL or SUPABASE_ANON_KEY. ' 'Provide them in the .env file.', textAlign: TextAlign.center, ), ), ), ), ); } }