Move push notification to client instead of db trigger
This commit is contained in:
+238
-127
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -9,8 +8,7 @@ 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';
|
||||
// removed unused imports
|
||||
import 'app.dart';
|
||||
import 'providers/notifications_provider.dart';
|
||||
import 'utils/app_time.dart';
|
||||
@@ -21,37 +19,68 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
// audio player not used at top-level; instantiate where needed
|
||||
StreamSubscription<String?>? _fcmTokenRefreshSub;
|
||||
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'] ?? '';
|
||||
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();
|
||||
|
||||
if (taskId.isNotEmpty &&
|
||||
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 a Task #$taskId',
|
||||
'body': '$actor has assigned you $taskLabel',
|
||||
};
|
||||
}
|
||||
|
||||
if (taskId.isNotEmpty &&
|
||||
if ((taskId.isNotEmpty || taskNumber.isNotEmpty) &&
|
||||
(type.contains('mention') ||
|
||||
data['action'] == 'mention' ||
|
||||
data['mentioned'] == 'true')) {
|
||||
return {
|
||||
'title': 'Mention',
|
||||
'body': '$actor has mentioned you in Task #$taskId',
|
||||
'body': '$actor has mentioned you in $taskLabel',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,10 +115,51 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
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();
|
||||
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 {
|
||||
@@ -182,8 +252,24 @@ Future<void> main() async {
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -205,11 +291,94 @@ Future<void> main() async {
|
||||
|
||||
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();
|
||||
// If actor_name is not present but actor_id is, try to resolve the
|
||||
// display name using the Supabase client (foreground only).
|
||||
Map<String, dynamic> dataForFormatting = Map<String, dynamic>.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') ?? '{}';
|
||||
@@ -285,28 +454,11 @@ Future<void> main() async {
|
||||
),
|
||||
);
|
||||
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
// Post-startup registration removed: token registration is handled
|
||||
// centrally in the auth state change listener to avoid duplicate inserts.
|
||||
}
|
||||
|
||||
class NotificationSoundObserver extends ProviderObserver {
|
||||
StreamSubscription<String?>? _tokenSub;
|
||||
|
||||
@override
|
||||
void didUpdateProvider(
|
||||
ProviderBase provider,
|
||||
@@ -315,95 +467,54 @@ class NotificationSoundObserver extends ProviderObserver {
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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<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;
|
||||
// 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));
|
||||
// // 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
// _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();
|
||||
|
||||
Reference in New Issue
Block a user