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" />
<meta-data
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>
<!-- Required to query activities that can process text, see:
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.NotificationManager
import android.media.AudioAttributes
import android.util.Log
import android.net.Uri
class App : Application() {
@ -13,7 +14,7 @@ class App : Application() {
}
private fun createTasqChannel() {
val channelId = "tasq_custom_sound_channel"
val channelId = "tasq_custom_sound_channel_3"
val name = "TasQ notifications"
val importance = NotificationManager.IMPORTANCE_HIGH
val channel = NotificationChannel(channelId, name, importance)
@ -25,8 +26,13 @@ class App : Application() {
.build()
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)
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_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 =
@ -30,16 +80,44 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
final FlutterLocalNotificationsPlugin localNotifPlugin =
FlutterLocalNotificationsPlugin();
// 2. Extract title and body from the DATA payload (not message.notification)
final String title = message.data['title'] ?? 'New Notification';
final String body = message.data['body'] ?? 'You have a new update in TasQ.';
// 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']!;
// 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;
// 3. Define the exact same channel specifics
const androidDetails = AndroidNotificationDetails(
'tasq_custom_sound_channel_2',
'tasq_custom_sound_channel_3',
'High Importance Notifications',
importance: Importance.max,
priority: Priority.high,
@ -125,13 +203,38 @@ Future<void> main() async {
await FirebaseMessaging.instance.requestPermission();
}
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
final notification = message.notification;
if (notification != null) {
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: notification.title ?? 'Notification',
body: notification.body ?? '',
title: formatted['title']!,
body: formatted['body']!,
payload: message.data['payload'],
);
}
@ -202,7 +305,6 @@ Future<void> main() async {
}
class NotificationSoundObserver extends ProviderObserver {
static final AudioPlayer _player = AudioPlayer();
StreamSubscription<String?>? _tokenSub;
@override
@ -217,12 +319,7 @@ class NotificationSoundObserver extends ProviderObserver {
final prev = previousValue as int?;
final next = newValue as int?;
if (prev != null && next != null && next > prev) {
_player.play(AssetSource('tasq_notification.wav'));
NotificationService.show(
id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
title: 'New notifications',
body: 'You have $next unread notifications.',
);
_maybeShowUnreadNotification(next);
}
}
@ -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 {
const _MissingConfigApp();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ class NotificationService {
FlutterLocalNotificationsPlugin();
static const String _channelId = 'tasq_default_channel';
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';
/// Call during app startup, after any necessary permissions have been