import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart' show kIsWeb; 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'; // removed unused imports import 'app.dart'; import 'providers/notifications_provider.dart'; import 'providers/notification_navigation_provider.dart'; import 'utils/app_time.dart'; import 'utils/notification_permission.dart'; import 'utils/location_permission.dart'; import 'services/notification_service.dart'; import 'services/notification_bridge.dart'; import 'services/background_location_service.dart'; import 'services/app_update_service.dart'; import 'widgets/update_dialog.dart'; import 'utils/navigation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; // audio player not used at top-level; instantiate where needed StreamSubscription? _fcmTokenRefreshSub; late ProviderContainer _globalProviderContainer; Map _formatNotificationFromData(Map data) { String actor = ''; if (data['actor_name'] != null) { actor = data['actor_name'].toString(); } else if (data['mentioner_name'] != null) { actor = data['mentioner_name'].toString(); } else if (data['user_name'] != null) { actor = data['user_name'].toString(); } else if (data['from'] != null) { actor = data['from'].toString(); } else if (data['actor'] != null) { final a = data['actor']; if (a is Map && a['name'] != null) { actor = a['name'].toString(); } else if (a is String) { try { final parsed = jsonDecode(a); if (parsed is Map && parsed['name'] != null) { actor = parsed['name'].toString(); } } catch (_) { // ignore JSON parse errors } } } if (actor.isEmpty) { actor = 'Someone'; } final taskNumber = (data['task_number'] ?? data['taskNumber'] ?? data['task_no']) ?.toString() ?? ''; final taskId = (data['task_id'] ?? data['taskId'] ?? data['task'])?.toString() ?? ''; final ticketId = data['ticket_id'] ?? data['ticketId'] ?? data['ticket'] ?? ''; final type = (data['type'] ?? '').toString().toLowerCase(); final taskLabel = taskNumber.isNotEmpty ? 'Task $taskNumber' : (taskId.isNotEmpty ? 'Task #$taskId' : 'Task'); if ((taskId.isNotEmpty || taskNumber.isNotEmpty) && (type.contains('assign') || data['action'] == 'assign' || data['assigned'] == 'true')) { return { 'title': 'Task assigned', 'body': '$actor has assigned you $taskLabel', }; } if ((taskId.isNotEmpty || taskNumber.isNotEmpty) && (type.contains('mention') || data['action'] == 'mention' || data['mentioned'] == 'true')) { return { 'title': 'Mention', 'body': '$actor has mentioned you in $taskLabel', }; } 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) String? stableId = (message.data['notification_id'] as String?) ?? message.messageId; if (stableId == null) { final sb = StringBuffer(); final taskNumber = (message.data['task_number'] ?? message.data['taskNumber'] ?? message.data['task_no']) ?.toString(); final taskId = (message.data['task_id'] ?? message.data['taskId'] ?? message.data['task']) ?.toString(); final ticketId = (message.data['ticket_id'] ?? message.data['ticketId'] ?? message.data['ticket']) ?.toString(); final type = (message.data['type'] ?? '').toString(); final actorId = (message.data['actor_id'] ?? message.data['actorId'] ?? message.data['actor']) ?.toString(); if (taskNumber != null && taskNumber.isNotEmpty) { sb.write('tasknum:$taskNumber'); } else if (taskId != null && taskId.isNotEmpty) { sb.write('task:$taskId'); } if (ticketId != null && ticketId.isNotEmpty) { if (sb.isNotEmpty) sb.write('|'); sb.write('ticket:$ticketId'); } if (type.isNotEmpty) { if (sb.isNotEmpty) sb.write('|'); sb.write('type:$type'); } if (actorId != null && actorId.isNotEmpty) { if (sb.isNotEmpty) sb.write('|'); sb.write('actor:$actorId'); } stableId = sb.isNotEmpty ? sb.toString() : (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; // Build payload string with ticket/task information for navigation final payloadParts = []; final taskId = (message.data['task_id'] ?? message.data['taskId'] ?? message.data['task']) ?.toString(); final ticketId = message.data['ticket_id'] ?? message.data['ticketId'] ?? message.data['ticket']?.toString() ?? ''; if (taskId != null && taskId.isNotEmpty) { payloadParts.add('task:$taskId'); } if (ticketId.isNotEmpty) { payloadParts.add('ticket:$ticketId'); } final payload = payloadParts.join('|').isNotEmpty ? payloadParts.join('|') : message.data['type']?.toString() ?? ''; // 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: payload, ); } 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 try { await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ).timeout(const Duration(seconds: 15)); } catch (e) { debugPrint('Firebase init failed or timed out: $e'); } FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); try { await dotenv.load(fileName: '.env').timeout(const Duration(seconds: 5)); } catch (e) { debugPrint('dotenv load failed or timed out: $e'); } 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; } try { await Supabase.initialize( url: supabaseUrl, anonKey: supabaseAnonKey, ).timeout(const Duration(seconds: 20)); } catch (e) { debugPrint('Supabase init failed or timed out: $e'); runApp(const _MissingConfigApp()); return; } // Initialize background location service (flutter_background_service) if (!kIsWeb) { try { await initBackgroundLocationService().timeout( const Duration(seconds: 10), ); } catch (e) { debugPrint('Background location service init failed or timed out: $e'); } } // 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) { // register current token and ensure we listen for refreshes await ctrl.registerFcmToken(token); try { // cancel any previous subscription await _fcmTokenRefreshSub?.cancel(); } catch (_) {} _fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen(( t, ) { debugPrint('token refreshed (auth listener): $t'); ctrl.registerFcmToken(t); }); } else if (event == AuthChangeEvent.signedOut) { // cancel token refresh subscription and unregister try { await _fcmTokenRefreshSub?.cancel(); } catch (_) {} _fcmTokenRefreshSub = null; 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`. try { final granted = await ensureNotificationPermission().timeout( const Duration(seconds: 10), ); if (!granted) { // we don't block startup, but it's worth logging so developers notice. // debugPrint('notification permission not granted'); } } catch (e) { debugPrint('Notification permission request failed or timed out: $e'); } // Request location permission at launch (same pattern as notification) try { final locationGranted = await ensureLocationPermission().timeout( const Duration(seconds: 10), ); if (!locationGranted) { // debugPrint('location permission not granted'); } } catch (e) { debugPrint('Location permission request failed or timed out: $e'); } // request FCM permission (iOS/Android13+) and handle foreground messages try { await FirebaseMessaging.instance.requestPermission().timeout( const Duration(seconds: 10), ); } catch (e) { debugPrint('FCM permission request failed or timed out: $e'); } } FirebaseMessaging.onMessage.listen((RemoteMessage message) async { // Prefer the data payload and format friendly messages, with dedupe. // If actor_name is not present but actor_id is, try to resolve the // display name using the Supabase client (foreground only). Map dataForFormatting = Map.from( message.data, ); try { final hasActorName = (dataForFormatting['actor_name'] ?? dataForFormatting['mentioner_name'] ?? dataForFormatting['user_name'] ?? dataForFormatting['from'] ?? dataForFormatting['actor']) != null; final actorId = dataForFormatting['actor_id'] ?? dataForFormatting['actorId'] ?? dataForFormatting['actor']; if (!hasActorName && actorId is String && actorId.isNotEmpty) { try { final client = Supabase.instance.client; final res = await client .from('profiles') .select('full_name,display_name,name') .eq('id', actorId) .maybeSingle(); if (res != null) { String? name; if (res['full_name'] != null) { name = res['full_name'].toString(); } else if (res['display_name'] != null) { name = res['display_name'].toString(); } else if (res['name'] != null) { name = res['name'].toString(); } if (name != null && name.isNotEmpty) { dataForFormatting['actor_name'] = name; } } } catch (_) { // ignore lookup failures and fall back to data payload } } } catch (_) {} final formatted = _formatNotificationFromData(dataForFormatting); String? stableId = (message.data['notification_id'] as String?) ?? message.messageId; if (stableId == null) { final sb = StringBuffer(); final taskNumber = (message.data['task_number'] ?? message.data['taskNumber'] ?? message.data['task_no']) ?.toString(); final taskId = (message.data['task_id'] ?? message.data['taskId'] ?? message.data['task']) ?.toString(); final ticketId = (message.data['ticket_id'] ?? message.data['ticketId'] ?? message.data['ticket']) ?.toString(); final type = (message.data['type'] ?? '').toString(); final actorId = (message.data['actor_id'] ?? message.data['actorId'] ?? message.data['actor']) ?.toString(); if (taskNumber != null && taskNumber.isNotEmpty) { sb.write('tasknum:$taskNumber'); } else if (taskId != null && taskId.isNotEmpty) { sb.write('task:$taskId'); } if (ticketId != null && ticketId.isNotEmpty) { if (sb.isNotEmpty) sb.write('|'); sb.write('ticket:$ticketId'); } if (type.isNotEmpty) { if (sb.isNotEmpty) sb.write('|'); sb.write('type:$type'); } if (actorId != null && actorId.isNotEmpty) { if (sb.isNotEmpty) sb.write('|'); sb.write('actor:$actorId'); } stableId = sb.isNotEmpty ? sb.toString() : (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)); // Build payload string with ticket/task information for navigation final payloadParts = []; final taskId = (message.data['task_id'] ?? message.data['taskId'] ?? message.data['task']) ?.toString(); final ticketId = message.data['ticket_id'] ?? message.data['ticketId'] ?? message.data['ticket']?.toString() ?? ''; if (taskId != null && taskId.isNotEmpty) { payloadParts.add('task:$taskId'); } if (ticketId.isNotEmpty) { payloadParts.add('ticket:$ticketId'); } final payload = payloadParts.join('|').isNotEmpty ? payloadParts.join('|') : message.data['payload']?.toString() ?? ''; NotificationService.show( id: DateTime.now().millisecondsSinceEpoch ~/ 1000, title: formatted['title']!, body: formatted['body']!, payload: 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: '', ); } }); // 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); // Create the global provider container BEFORE initializing local // notifications, because the tap callback needs to write to it and // flutter_local_notifications may fire the callback synchronously if // the app was launched by tapping a notification. _globalProviderContainer = ProviderContainer( observers: [NotificationSoundObserver()], ); // 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 "ticket:ID", // "task:ID", "tasknum:NUMBER", or a combination separated by "|" final payload = response.payload; if (payload != null && payload.isNotEmpty) { // Parse the payload to extract ticket and task information final parts = payload.split('|'); String? ticketId; String? taskId; for (final part in parts) { if (part.startsWith('ticket:')) { ticketId = part.substring('ticket:'.length); } else if (part.startsWith('task:')) { taskId = part.substring('task:'.length); } } // Update the pending navigation provider. // Prefer task over ticket — assignment notifications include both // IDs but the primary entity is the task. if (taskId != null && taskId.isNotEmpty) { _globalProviderContainer .read(pendingNotificationNavigationProvider.notifier) .state = ( type: 'task', id: taskId, ); } else if (ticketId != null && ticketId.isNotEmpty) { _globalProviderContainer .read(pendingNotificationNavigationProvider.notifier) .state = ( type: 'ticket', id: ticketId, ); } } }, ); runApp( UncontrolledProviderScope( container: _globalProviderContainer, child: const NotificationBridge(child: TasqApp()), ), ); // perform update check once the first frame has rendered; errors are // intentionally swallowed so a network outage doesn't block startup. WidgetsBinding.instance.addPostFrameCallback((_) async { try { final info = await AppUpdateService.instance.checkForUpdate(); if (info.isUpdateAvailable) { showDialog( context: globalNavigatorKey.currentContext!, barrierDismissible: !info.isForceUpdate, builder: (_) => UpdateDialog(info: info), ); } } catch (e) { debugPrint('update check failed: $e'); } }); // Post-startup registration removed: token registration is handled // centrally in the auth state change listener to avoid duplicate inserts. } class NotificationSoundObserver extends ProviderObserver { @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); // } // } // Profile changes no longer perform token registration here. // Token registration is handled centrally in the auth state change listener // to avoid duplicate DB rows and duplicate deliveries. } } // 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, ), ), ), ), ); } }