chore(notifications): fix conflig when show notification in android

This commit is contained in:
Tran Anh Tuan
2025-10-23 16:57:10 +07:00
parent 9bff11a0b1
commit 2afc71e24c
16 changed files with 567 additions and 422 deletions

View File

@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:alarm/alarm.dart';
@pragma('vm:entry-point')
class AlarmServices {
Future<void> showAlarm(String title, String body) async {
final DateTime now = DateTime.now();

View File

@@ -2,6 +2,8 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
@@ -27,8 +29,6 @@ import '../constant/enums/local_keys_enums.dart';
import '../network/network_manager.dart';
class APIServices {
Map<String, String> headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
@@ -92,7 +92,7 @@ class APIServices {
// try {
// return await apiCall();
// } catch (e) {
// AppLoggerUtils.error(e.toString());
// return Future.error(e);
// }
@@ -104,12 +104,33 @@ class APIServices {
bool checkMounted = true,
}) async {
try {
// Quick connectivity check before attempting network calls.
final conn = await Connectivity().checkConnectivity();
if (conn == ConnectivityResult.none) {
AppLoggerUtils.warning('No network connectivity');
if (checkMounted && context.mounted) {
showErrorTopSnackBarCustom(context, "Không có kết nối mạng");
}
return Future.error(const SocketException('No network connectivity'));
}
return await apiCall();
} catch (e) {
} on SocketException catch (e, stackTrace) {
// Network-related errors (DNS, timeout, host lookup...)
AppLoggerUtils.warning('Network error when calling API: $e');
if (checkMounted && context.mounted) {
AppLoggerUtils.error("Không có kết nối mạng");
}
return Future.error(e);
} catch (e, stackTrace) {
// If widget was unmounted (e.g. background isolate), preserve previous behavior
if (checkMounted && !context.mounted) {
return Future.error('Widget not mounted');
}
showErrorTopSnackBarCustom(context, "Lỗi hệ thống");
AppLoggerUtils.error("Lỗi hệ thống khi gọi API", e, stackTrace);
if (checkMounted && context.mounted) {
showErrorTopSnackBarCustom(context, "Lỗi hệ thống");
}
return Future.error(e);
}
}
@@ -308,7 +329,6 @@ class APIServices {
.getDataFromServer(APIPathConstants.PROVINCES_PATH),
parser: (json) => Province.fromJsonDynamicList(json['items']),
errorMessage: 'Lỗi khi GET /${APIPathConstants.PROVINCES_PATH}');
}
Future<List<Province>> getProvincesByName(String name) async {
@@ -319,7 +339,6 @@ class APIServices {
parser: (json) => Province.fromJsonDynamicList(json['items']),
errorMessage: 'Lỗi khi GET /${APIPathConstants.PROVINCES_PATH}/$name',
);
}
Future<Province> getProvinceByID(String provinceID) async {

View File

@@ -4,20 +4,21 @@ import 'dart:math' as math;
import 'package:alarm/alarm.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'
hide NotificationSettings;
as firebase_messaging;
import 'package:flutter/material.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../utils/app_logger_utils.dart';
import '../../firebase_options.dart';
import '../../main.dart';
import 'alarm_services.dart';
@pragma('vm:entry-point')
class NotificationServices {
static final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin();
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final firebase_messaging.FirebaseMessaging _messaging =
firebase_messaging.FirebaseMessaging.instance;
AlarmServices alarmServices = AlarmServices();
Future<void> initialize() async {
@@ -25,36 +26,51 @@ class NotificationServices {
dev.log("NotificationService initialized");
}
@pragma('vm:entry-point')
Future<void> initializeLocalNotifications() async {
try {
const AndroidInitializationSettings androidInitializationSettings =
const androidInitSettings =
AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings iosInitializationSettings =
DarwinInitializationSettings();
const InitializationSettings initializationSettings =
InitializationSettings(
android: androidInitializationSettings,
iOS: iosInitializationSettings,
final darwinInitSettings = DarwinInitializationSettings(
onDidReceiveLocalNotification: _onDidReceiveLocalNotification,
);
final initSettings = InitializationSettings(
android: androidInitSettings,
iOS: darwinInitSettings,
);
await _notificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: (NotificationResponse response) {
dev.log(
"Người dùng click thông báo ở foreground với payload: ${response.payload}");
handleMessage(response.payload, controller);
},
initSettings,
onDidReceiveNotificationResponse: _onDidReceiveNotificationResponse,
);
dev.log("Local notifications initialized");
} catch (e) {
dev.log("Error initializing local notifications: $e");
AppLoggerUtils.error("Failed to initialize local notifications", e);
}
}
Future<void> _onDidReceiveLocalNotification(
int id, String? title, String? body, String? payload) async {
// Handle local notification tap when app is foreground (iOS)
dev.log("Local notification tapped (foreground): payload=$payload");
}
Future<void> _onDidReceiveNotificationResponse(
NotificationResponse response) async {
// Handle local notification tap (both foreground & background)
final payload = response.payload;
dev.log("Notification tapped: payload=$payload");
if (payload != null && payload.isNotEmpty) {
final controller = PersistentTabController(initialIndex: 0);
handleMessage(payload, controller);
}
}
void firebaseInit(BuildContext context) {
FirebaseMessaging.onMessage.listen((message) {
dev.log("Foreground message payload: ${message.toMap()}");
_showNotification(message);
firebase_messaging.FirebaseMessaging.onMessage.listen((message) {
_handleForegroundMessage(message);
});
}
@@ -66,75 +82,156 @@ class NotificationServices {
static Future<bool?> requestNotificationPermission() async {
try {
if (Platform.isAndroid) {
return await _notificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
} else if (Platform.isIOS) {
return await _notificationsPlugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(alert: true, sound: true, badge: true);
}
return null;
final messaging = firebase_messaging.FirebaseMessaging.instance;
final settings = await messaging.requestPermission(
alert: true,
announcement: false,
badge: true,
provisional: false,
sound: true,
);
return settings.authorizationStatus ==
firebase_messaging.AuthorizationStatus.authorized;
} catch (e) {
dev.log("Error requesting notification permission: $e");
AppLoggerUtils.error("Failed to request notification permission", e);
return null;
}
}
Future<void> _showNotification(RemoteMessage message) async {
/// Xử lý message khi app foreground
Future<void> _handleForegroundMessage(
firebase_messaging.RemoteMessage message) async {
try {
// Early validation of notification data
final title = message.notification?.title ?? "Title" as String?;
final body = message.notification?.body ?? "Body" as String?;
final type = message.data['type'] as String? ?? 'normal';
final title =
message.notification?.title ?? message.data['title'] ?? 'SFM';
final body = message.notification?.body ?? message.data['body'] ?? '';
if (title == null || body == null) {
dev.log('Skipping notification: missing title or body',
name: 'Notification');
return;
}
dev.log('Foreground message: type=$type, title=$title, body=$body');
// Handle smoke warning notifications
if (type == 'smoke_warning') {
await alarmServices.showAlarm(title, body);
dev.log('Displayed smoke warning notification', name: 'Notification');
return;
// Hiển thị alarm, không hiển thị notification
await _showAlarm(title, body);
} else {
// Hiển thị notification local
await _showForegroundNotification(title, body, type);
}
} catch (e, stackTrace) {
AppLoggerUtils.error('Error handling foreground message', e, stackTrace);
}
}
// Create notification channel
final channelId = math.Random.secure().nextInt(1000000).toString();
const channelName = 'High Importance Notification';
const channelDescription = 'Channel description';
/// Hiển thị alarm (smoke warning)
Future<void> _showAlarm(String title, String body) async {
try {
await Alarm.init();
// final androidChannel = AndroidNotificationChannel(
// channelId,
// channelName,
// importance: Importance.max,
// description: channelDescription,
// );
final alarmSettings = AlarmSettings(
id: 42,
dateTime: DateTime.now(),
assetAudioPath: 'assets/sounds/warning_alarm.mp3',
loopAudio: true,
vibrate: true,
warningNotificationOnKill: Platform.isIOS,
androidFullScreenIntent: true,
volumeSettings: VolumeSettings.fade(
volume: 0.8,
fadeDuration: const Duration(seconds: 5),
volumeEnforced: true,
),
notificationSettings: NotificationSettings(
title: title,
body: body,
stopButton: 'Dừng thông báo',
icon: "ic_launcher",
),
);
final androidPlugin =
_notificationsPlugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
await Alarm.set(alarmSettings: alarmSettings);
dev.log('Alarm set successfully: $title');
} catch (e, stackTrace) {
AppLoggerUtils.error('Failed to set alarm', e, stackTrace);
}
}
// Delete existing channel to prevent conflicts
await androidPlugin?.deleteNotificationChannel(channelId);
// Configure notification details
/// Hiển thị notification local khi app foreground
Future<void> _showForegroundNotification(
String title, String body, String type) async {
try {
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
'sfm_channel_${math.Random.secure().nextInt(1000000)}',
'SFM Notifications',
channelDescription: 'Notifications from SFM app',
sound: getSound(type),
importance: Importance.max,
priority: Priority.high,
ticker: 'ticker',
);
const iosDetails = DarwinNotificationDetails(
final iosDetails = DarwinNotificationDetails(
sound: _getSoundNameForIOS(type),
presentAlert: true,
presentBadge: true,
presentBanner: true,
presentSound: true,
);
final notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notificationsPlugin.show(
math.Random.secure().nextInt(1000000),
title,
body,
notificationDetails,
payload: type,
);
dev.log('Foreground notification shown: title=$title, type=$type');
} catch (e, stackTrace) {
AppLoggerUtils.error(
'Failed to show foreground notification', e, stackTrace);
}
}
/// Hiển thị notification cho background/terminate
Future<void> showBackgroundOrTerminateNotification(
firebase_messaging.RemoteMessage message) async {
try {
final type = message.data['type'] as String? ?? 'normal';
final title =
message.notification?.title ?? message.data['title'] ?? 'SFM';
final body = message.notification?.body ?? message.data['body'] ?? '';
dev.log('Background/terminate notification: type=$type, title=$title');
// Cho Android: nếu type = smoke_warning, hiển thị alarm
// Cho iOS: chỉ hiển thị notification, APNs payload sẽ phát sound natively
if (type == 'smoke_warning' && Platform.isAndroid) {
dev.log('→ Showing alarm for Android background');
await _showAlarm(title, body);
return;
}
// Hiển thị notification (cho cả iOS lẫn Android, nhưng iOS không có alarm)
final channelId = math.Random.secure().nextInt(1000000).toString();
const channelName = 'SFM Notifications';
const channelDescription = 'Notifications from SFM app';
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
sound: getSound(type),
importance: Importance.max,
priority: Priority.high,
ticker: 'ticker',
);
final iosDetails = DarwinNotificationDetails(
sound: _getSoundNameForIOS(type),
presentAlert: true,
presentBadge: true,
presentBanner: true,
@@ -146,7 +243,6 @@ class NotificationServices {
iOS: iosDetails,
);
// Show notification
await _notificationsPlugin.show(
int.parse(channelId),
title,
@@ -156,16 +252,10 @@ class NotificationServices {
);
dev.log(
'Displayed notification - title: $title, body: $body, type: $type',
name: 'Notification',
);
'Background/terminate notification shown: title=$title, type=$type');
} catch (e, stackTrace) {
dev.log(
'Failed to show notification: $e',
name: 'Notification',
error: e,
stackTrace: stackTrace,
);
AppLoggerUtils.error(
'Failed to show background/terminate notification', e, stackTrace);
}
}
@@ -179,153 +269,110 @@ class NotificationServices {
} else if (type == "battery_warning") {
return const RawResourceAndroidNotificationSound("new_alarm");
} else {
return const RawResourceAndroidNotificationSound("normal");
return const RawResourceAndroidNotificationSound("warning_alarm");
}
}
String _getSoundNameForIOS(String type) {
// iOS tìm file trong app bundle (không có đuôi)
if (type == "smoke_warning" || type == "battery_warning") {
return "warning_alarm"; // file: warning_alarm.caf trong bundle
}
return "default";
}
void handleMessage(String? payload, PersistentTabController controller) {
dev.log("Handling notification tap with payload: $payload");
AppLoggerUtils.info("Handling notification tap with payload: $payload");
controller.jumpToTab(1);
if (payload == "smoke_warning") {
// TODO: Navigate to smoke warning screen nếu cần
// NavigationRouter.navigateToSmokeWarningScreen();
} else if (payload == "battery_warning") {
// TODO: Navigate to battery warning screen nếu cần
// NavigationRouter.navigateToBatteryWarningScreen();
}
}
Future<void> setupInteractMessage(PersistentTabController controller) async {
// Khi app terminated
RemoteMessage? initialMessage =
await FirebaseMessaging.instance.getInitialMessage();
// Khi app terminated, được mở bằng notification
firebase_messaging.RemoteMessage? initialMessage =
await firebase_messaging.FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
handleMessage(initialMessage.data['type'], controller);
} catch (e, stack) {
dev.log("Error handling initial message: $e\n$stack");
}
final type = initialMessage.data['type'] as String?;
handleMessage(type, controller);
});
}
// Khi app ở background
FirebaseMessaging.onMessageOpenedApp.listen((message) {
// Khi app background, user tap notification
firebase_messaging.FirebaseMessaging.onMessageOpenedApp.listen((message) {
try {
handleMessage(message.data['type'], controller);
} catch (e, stack) {
dev.log("Error in onMessageOpenedApp: $e\n$stack");
final type = message.data['type'] as String?;
handleMessage(type, controller);
} catch (e, stackTrace) {
AppLoggerUtils.error('Error in onMessageOpenedApp', e, stackTrace);
}
});
}
Future<void> showBackgroundOrTerminateNotification(
RemoteMessage message) async {
@pragma('vm:entry-point')
static Future<void> firebaseMessagingBackgroundHandler(
firebase_messaging.RemoteMessage message) async {
try {
// Early validation of notification data
final title = message.data['title'] as String?;
final body = message.data['body'] as String?;
final type = message.data['type'] as String? ?? 'normal';
// Log to device storage for debugging background isolate
dev.log('═══ BACKGROUND HANDLER STARTED ═══');
dev.log('Message data: ${message.data}');
dev.log('Notification: ${message.notification}');
if (title == null || body == null) {
dev.log('Skipping notification: missing title or body',
name: 'Notification');
return;
await Alarm.init();
dev.log('✓ Alarm initialized');
if (Firebase.apps.isEmpty) {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
name: "sfm-notification",
);
dev.log('✓ Firebase initialized in background');
} else {
dev.log('✓ Firebase already initialized');
}
// Create notification channel
final channelId = math.Random.secure().nextInt(1000000).toString();
const channelName = 'High Importance Notification';
const channelDescription = 'Channel description';
final type = message.data['type'] as String? ?? 'normal';
final title = message.notification?.title ??
message.data['title'] ??
'Thông báo từ SmartFM';
final body = message.notification?.body ??
message.data['body'] ??
'Bạn có một thông báo mới';
// Configure notification details
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
sound: getSound(type),
importance: Importance.max,
priority: Priority.high,
ticker: 'ticker',
);
dev.log('Message type: $type');
dev.log('Title: $title');
dev.log('Body: $body');
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentBanner: true,
presentSound: true,
);
// Logic: Hiển thị local notification cho background/terminate
// iOS sẽ phát sound natively thông qua APNs payload (aps.sound)
// Android sẽ phát sound thông qua local notification
try {
final notificationService = NotificationServices();
await notificationService.initialize();
dev.log('✓ Notification service initialized');
final notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await notificationService
.showBackgroundOrTerminateNotification(message);
dev.log('✓ Background/terminate notification shown');
} catch (notifError, notifStackTrace) {
dev.log('✗ ERROR showing notification: $notifError');
dev.log('Stack trace: $notifStackTrace');
rethrow;
}
// Show notification
await _notificationsPlugin.show(
int.parse(channelId),
title,
body,
notificationDetails,
payload: type,
);
dev.log(
'Displayed notification - title: $title, body: $body, type: $type',
name: 'Notification',
);
dev.log('═══ BACKGROUND HANDLER COMPLETED ═══');
} catch (e, stackTrace) {
dev.log(
'Failed to show notification: $e',
name: 'Notification',
error: e,
stackTrace: stackTrace,
);
dev.log('✗ BACKGROUND HANDLER FAILED');
dev.log('Error: $e');
dev.log('Stack trace: $stackTrace');
}
}
}
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
try {
AppLoggerUtils.warning("Background handler started: ${message.data}");
await Alarm.init();
if (Firebase.apps.isEmpty) {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
name: "sfm-notification");
AppLoggerUtils.warning(
"Firebase đã được khởi tạo trong background handler");
} else {
AppLoggerUtils.warning("Firebase đã được khởi tạo trước đó");
}
AppLoggerUtils.warning(message.toString());
AppLoggerUtils.warning(message.data.toString());
AppLoggerUtils.warning(message.data["notification"].toString());
String? title = message.data['title'];
String? body = message.data['body'];
String type = message.data['type'] ?? "normal";
final notificationService = NotificationServices();
await notificationService.initialize();
final alarmSettings = AlarmSettings(
id: 42,
dateTime: DateTime.now(),
assetAudioPath: 'assets/sounds/warning_alarm.mp3',
loopAudio: true,
vibrate: true,
warningNotificationOnKill: Platform.isIOS,
androidFullScreenIntent: true,
volumeSettings: VolumeSettings.fade(
volume: 0.8,
fadeDuration: const Duration(seconds: 5),
volumeEnforced: true,
),
notificationSettings: NotificationSettings(
title: title ?? "SFM",
body: body ?? "",
stopButton: 'Dừng thông báo',
icon: "ic_launcher",
),
);
if (type == "smoke_warning") {
await Alarm.set(alarmSettings: alarmSettings);
} else {
await notificationService.showBackgroundOrTerminateNotification(message);
}
} catch (e) {
AppLoggerUtils.warning("Error in background handler: $e");
}
}