tasq/lib/main.dart

429 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String, String> _formatNotificationFromData(Map<String, dynamic> 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<void> _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<String, dynamic> 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<void> 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 reenabled from the
// system settings. The helper uses `permission_handler`.
final granted = await ensureNotificationPermission();
if (!granted) {
// we dont block startup, but its 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<String, dynamic> 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<NavigatorState>();
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<String?>? _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<String, dynamic> 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,
),
),
),
),
);
}
}