A much more detailed notification
This commit is contained in:
parent
9cc99e612a
commit
dab43a7f30
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
166
lib/main.dart
166
lib/main.dart
|
|
@ -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: notification.title ?? 'Notification',
|
||||
body: notification.body ?? '',
|
||||
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'],
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? {},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user