A much more detailed notification

This commit is contained in:
Marc Rejohn Castillano 2026-02-27 07:05:08 +08:00
parent 9cc99e612a
commit dab43a7f30
9 changed files with 178 additions and 41 deletions

View File

@ -47,7 +47,7 @@
android:value="2" /> android:value="2" />
<meta-data <meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id" android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="tasq_custom_sound_channel_2" /> android:value="tasq_custom_sound_channel_3" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View File

@ -4,6 +4,7 @@ import android.app.Application
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.media.AudioAttributes import android.media.AudioAttributes
import android.util.Log
import android.net.Uri import android.net.Uri
class App : Application() { class App : Application() {
@ -13,7 +14,7 @@ class App : Application() {
} }
private fun createTasqChannel() { private fun createTasqChannel() {
val channelId = "tasq_custom_sound_channel" val channelId = "tasq_custom_sound_channel_3"
val name = "TasQ notifications" val name = "TasQ notifications"
val importance = NotificationManager.IMPORTANCE_HIGH val importance = NotificationManager.IMPORTANCE_HIGH
val channel = NotificationChannel(channelId, name, importance) val channel = NotificationChannel(channelId, name, importance)
@ -25,8 +26,13 @@ class App : Application() {
.build() .build()
channel.setSound(soundUri, audioAttributes) channel.setSound(soundUri, audioAttributes)
// Ensure we do NOT bypass Do Not Disturb — let the system enforce silent/vibrate modes
channel.setBypassDnd(false)
// Allow vibration; system will respect device vibrate settings
channel.enableVibration(true)
val nm = getSystemService(NotificationManager::class.java) val nm = getSystemService(NotificationManager::class.java)
nm?.createNotificationChannel(channel) nm?.createNotificationChannel(channel)
Log.d("App", "Created notification channel: $channelId with sound=$soundUri")
} }
} }

View File

@ -18,6 +18,56 @@ import 'utils/notification_permission.dart';
import 'services/notification_service.dart'; import 'services/notification_service.dart';
import 'services/notification_bridge.dart'; import 'services/notification_bridge.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.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 // Initialize the plugin
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
@ -30,16 +80,44 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
final FlutterLocalNotificationsPlugin localNotifPlugin = final FlutterLocalNotificationsPlugin localNotifPlugin =
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
// 2. Extract title and body from the DATA payload (not message.notification) // 2. Extract and format title/body from the DATA payload (not message.notification)
final String title = message.data['title'] ?? 'New Notification'; final formatted = _formatNotificationFromData(message.data);
final String body = message.data['body'] ?? 'You have a new update in TasQ.'; final String title = formatted['title']!;
final String body = formatted['body']!;
// Create a unique ID // 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; final int id = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// 3. Define the exact same channel specifics // 3. Define the exact same channel specifics
const androidDetails = AndroidNotificationDetails( const androidDetails = AndroidNotificationDetails(
'tasq_custom_sound_channel_2', 'tasq_custom_sound_channel_3',
'High Importance Notifications', 'High Importance Notifications',
importance: Importance.max, importance: Importance.max,
priority: Priority.high, priority: Priority.high,
@ -125,13 +203,38 @@ Future<void> main() async {
await FirebaseMessaging.instance.requestPermission(); await FirebaseMessaging.instance.requestPermission();
} }
FirebaseMessaging.onMessage.listen((RemoteMessage message) { FirebaseMessaging.onMessage.listen((RemoteMessage message) async {
final notification = message.notification; // Prefer the data payload and format friendly messages, with dedupe.
if (notification != null) { 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( NotificationService.show(
id: DateTime.now().millisecondsSinceEpoch ~/ 1000, id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
title: notification.title ?? 'Notification', title: formatted['title']!,
body: notification.body ?? '', body: formatted['body']!,
payload: message.data['payload'], payload: message.data['payload'],
); );
} }
@ -202,7 +305,6 @@ Future<void> main() async {
} }
class NotificationSoundObserver extends ProviderObserver { class NotificationSoundObserver extends ProviderObserver {
static final AudioPlayer _player = AudioPlayer();
StreamSubscription<String?>? _tokenSub; StreamSubscription<String?>? _tokenSub;
@override @override
@ -217,12 +319,7 @@ class NotificationSoundObserver extends ProviderObserver {
final prev = previousValue as int?; final prev = previousValue as int?;
final next = newValue as int?; final next = newValue as int?;
if (prev != null && next != null && next > prev) { if (prev != null && next != null && next > prev) {
_player.play(AssetSource('tasq_notification.wav')); _maybeShowUnreadNotification(next);
NotificationService.show(
id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
title: 'New notifications',
body: 'You have $next unread notifications.',
);
} }
} }
@ -273,6 +370,41 @@ class NotificationSoundObserver extends ProviderObserver {
} }
} }
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 { class _MissingConfigApp extends StatelessWidget {
const _MissingConfigApp(); const _MissingConfigApp();

View File

@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'notifications_provider.dart'; import 'notifications_provider.dart';
import '../utils/device_id.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';

View File

@ -121,14 +121,14 @@ class NotificationsController {
actorId: actorId, actorId: actorId,
fields: { fields: {
'message_id': messageId, 'message_id': messageId,
if (ticketId != null) 'ticket_id': ticketId, ...?(ticketId != null ? {'ticket_id': ticketId} : null),
if (taskId != null) 'task_id': taskId, ...?(taskId != null ? {'task_id': taskId} : null),
}, },
pushTitle: 'New mention', pushTitle: 'New mention',
pushBody: 'You were mentioned in a message', pushBody: 'You were mentioned in a message',
pushData: { pushData: {
if (ticketId != null) 'ticket_id': ticketId, ...?(ticketId != null ? {'ticket_id': ticketId} : null),
if (taskId != null) 'task_id': taskId, ...?(taskId != null ? {'task_id': taskId} : null),
}, },
); );
} }
@ -240,8 +240,8 @@ class NotificationsController {
return; return;
} }
final bodyPayload = <String, dynamic>{ final bodyPayload = <String, dynamic>{
if (tokens != null) 'tokens': tokens, 'tokens': tokens ?? [],
if (userIds != null) 'user_ids': userIds, 'user_ids': userIds ?? [],
'title': title, 'title': title,
'body': body, 'body': body,
'data': data ?? {}, 'data': data ?? {},

View File

@ -197,7 +197,7 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
// 2. in_progress preserve recent order (created_at asc) // 2. in_progress preserve recent order (created_at asc)
// 3. completed order by numeric task_number when available (asc) // 3. completed order by numeric task_number when available (asc)
// 4. other statuses fallback to queue_order then created_at // 4. other statuses fallback to queue_order then created_at
final statusRank = (String s) { int statusRank(String s) {
switch (s) { switch (s) {
case 'queued': case 'queued':
return 0; return 0;
@ -208,9 +208,9 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
default: default:
return 3; return 3;
} }
}; }
int? _parseTaskNumber(Task t) { int? parseTaskNumber(Task t) {
final tn = t.taskNumber; final tn = t.taskNumber;
if (tn == null) return null; if (tn == null) return null;
final m = RegExp(r'\d+').firstMatch(tn); final m = RegExp(r'\d+').firstMatch(tn);
@ -243,8 +243,8 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
if (ra == 2) { if (ra == 2) {
// completed: prefer numeric task_number DESC when present // completed: prefer numeric task_number DESC when present
final an = _parseTaskNumber(a); final an = parseTaskNumber(a);
final bn = _parseTaskNumber(b); final bn = parseTaskNumber(b);
if (an != null && bn != null) return bn.compareTo(an); if (an != null && bn != null) return bn.compareTo(an);
if (an != null) return -1; if (an != null) return -1;
if (bn != null) return 1; if (bn != null) return 1;

View File

@ -76,7 +76,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tasksAsync = ref.watch(tasksProvider); final tasksAsync = ref.watch(tasksProvider);
final taskQuery = ref.watch(tasksQueryProvider); ref.watch(tasksQueryProvider);
final ticketsAsync = ref.watch(ticketsProvider); final ticketsAsync = ref.watch(ticketsProvider);
final officesAsync = ref.watch(officesProvider); final officesAsync = ref.watch(officesProvider);
final profileAsync = ref.watch(currentProfileProvider); final profileAsync = ref.watch(currentProfileProvider);

View File

@ -934,7 +934,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
); );
}, },
loading: () => const SizedBox.shrink(), loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(), error: (error, stack) => const SizedBox.shrink(),
), ),
], ],
), ),
@ -946,7 +946,6 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
), ),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
final outerContext = context;
final subject = subjectCtrl.text.trim(); final subject = subjectCtrl.text.trim();
final desc = descCtrl.text.trim(); final desc = descCtrl.text.trim();
try { try {
@ -959,14 +958,15 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
officeId: selectedOffice, officeId: selectedOffice,
); );
if (!mounted) return; if (!mounted) return;
Navigator.of(outerContext).pop(); WidgetsBinding.instance.addPostFrameCallback((_) {
showSuccessSnackBar(outerContext, 'Ticket updated'); Navigator.of(context).pop();
showSuccessSnackBar(context, 'Ticket updated');
});
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
showErrorSnackBar( WidgetsBinding.instance.addPostFrameCallback((_) {
outerContext, showErrorSnackBar(context, 'Failed to update ticket: $e');
'Failed to update ticket: $e', });
);
} }
}, },
child: const Text('Save'), child: const Text('Save'),

View File

@ -9,7 +9,7 @@ class NotificationService {
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
static const String _channelId = 'tasq_default_channel'; static const String _channelId = 'tasq_default_channel';
static const String _channelName = 'General'; static const String _channelName = 'General';
static const String _highChannelId = 'tasq_custom_sound_channel_2'; static const String _highChannelId = 'tasq_custom_sound_channel_3';
static const String _highChannelName = 'High Priority'; static const String _highChannelName = 'High Priority';
/// Call during app startup, after any necessary permissions have been /// Call during app startup, after any necessary permissions have been