A much more detailed notification
This commit is contained in:
parent
9cc99e612a
commit
dab43a7f30
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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_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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ?? {},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user