build local notification with downloaded image; PDF via local file

This commit is contained in:
Mohamad Mahdi Jebeli 2026-06-21 13:15:50 +03:30
parent d5a6007f7d
commit 339499501f
4 changed files with 185 additions and 42 deletions

View File

@ -4,6 +4,7 @@ import 'dart:io';
import 'package:app_links/app_links.dart'; import 'package:app_links/app_links.dart';
import 'package:bot_toast/bot_toast.dart'; import 'package:bot_toast/bot_toast.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:flutter_downloader/flutter_downloader.dart';
@ -46,6 +47,30 @@ Future<void> _backgroundCallbackHomeWidget(Uri? uri) async {
} }
} }
/// Firebase background message handler.
///
/// Runs in a separate isolate when the app is killed/background and a
/// data-only push arrives (which is how content-service broadcasts ship
/// notifications now). Must be a top-level function annotated with
/// `vm:entry-point` so Flutter's tree-shaker keeps it.
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
WidgetsFlutterBinding.ensureInitialized();
try {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
} catch (e) {
if (kDebugMode) print('bg handler: Firebase.initializeApp failed: $e');
}
try {
await NotificationService.initializeNotification();
await NotificationService.showFirebaseNotification(message);
} catch (e) {
if (kDebugMode) print('bg handler: showFirebaseNotification failed: $e');
}
}
void main() async { void main() async {
runZonedGuarded( runZonedGuarded(
() async { () async {
@ -89,6 +114,15 @@ void main() async {
}, },
); );
// Register the background handler before any other Firebase
// messaging setup. Content-service ships data-only pushes so we
// need to build the notification ourselves when the app is killed.
if (!kIsWeb) {
FirebaseMessaging.onBackgroundMessage(
_firebaseMessagingBackgroundHandler,
);
}
if (!kIsWeb) { if (!kIsWeb) {
await FirebaseApi().initNotification().timeout( await FirebaseApi().initNotification().timeout(
const Duration(seconds: 5), const Duration(seconds: 5),

View File

@ -6,6 +6,9 @@ class NotificationMessage {
String? type; String? type;
String? link; String? link;
String? image; String? image;
// Remote cover image URL set by content-service broadcasts. Used by the
// local notification builder to render a BigPictureStyle preview.
String? imageUrl;
String? photo; String? photo;
String? groupKey; String? groupKey;
String? userId; String? userId;
@ -18,6 +21,7 @@ class NotificationMessage {
this.type, this.type,
this.link, this.link,
this.image, this.image,
this.imageUrl,
this.photo, this.photo,
this.groupKey, this.groupKey,
this.userId, this.userId,
@ -31,6 +35,7 @@ class NotificationMessage {
type = json['type']; type = json['type'];
link = json['link']; link = json['link'];
image = json['image']; image = json['image'];
imageUrl = json['imageUrl'];
photo = json['photo']; photo = json['photo'];
groupKey = json['groupKey']; groupKey = json['groupKey'];
userId = json['userId']; userId = json['userId'];
@ -45,6 +50,7 @@ class NotificationMessage {
data['type'] = type; data['type'] = type;
data['link'] = link; data['link'] = link;
data['image'] = image; data['image'] = image;
data['imageUrl'] = imageUrl;
data['photo'] = photo; data['photo'] = photo;
data['groupKey'] = groupKey; data['groupKey'] = groupKey;
data['userId'] = userId; data['userId'] = userId;
@ -60,6 +66,7 @@ class NotificationMessage {
data['type'] = type ?? ''; data['type'] = type ?? '';
data['link'] = link ?? ''; data['link'] = link ?? '';
data['image'] = image ?? ''; data['image'] = image ?? '';
data['imageUrl'] = imageUrl ?? '';
data['photo'] = photo ?? ''; data['photo'] = photo ?? '';
data['userId'] = userId ?? ''; data['userId'] = userId ?? '';
return data; return data;

View File

@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:didvan/models/notification_message.dart'; import 'package:didvan/models/notification_message.dart';
@ -8,6 +9,8 @@ import 'package:didvan/services/storage/storage.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
class NotificationService { class NotificationService {
static final FlutterLocalNotificationsPlugin static final FlutterLocalNotificationsPlugin
@ -89,7 +92,9 @@ class NotificationService {
print("Notification Type: ${data.notificationType}"); print("Notification Type: ${data.notificationType}");
} }
if (data.notificationType!.contains('3')) { // Widget-refresh pushes carry notificationType=3 and skip the UI.
if (data.notificationType == '3' ||
(data.notificationType?.contains('3') ?? false)) {
if (kDebugMode) { if (kDebugMode) {
print("Widget notification - calling fetchWidget()"); print("Widget notification - calling fetchWidget()");
} }
@ -105,8 +110,35 @@ class NotificationService {
} }
} }
/// Downloads [url] into a temp file and returns its absolute path. Used by
/// `BigPictureStyleInformation` which needs a local file path. Returns
/// null on any failure so we degrade to a no-image notification rather
/// than crashing.
static Future<String?> _downloadImageToTemp(String url) async {
try {
final res = await http.get(Uri.parse(url));
if (res.statusCode != 200) {
if (kDebugMode) {
print('image download failed: HTTP ${res.statusCode}');
}
return null;
}
final dir = await getTemporaryDirectory();
final file = File(
'${dir.path}/notif_${DateTime.now().millisecondsSinceEpoch}.img',
);
await file.writeAsBytes(res.bodyBytes, flush: true);
return file.path;
} catch (e) {
if (kDebugMode) print('image download exception: $e');
return null;
}
}
static Future<void> showNotification(NotificationMessage data) async { static Future<void> showNotification(NotificationMessage data) async {
if (data.notificationType!.contains('2')) { final notifType = data.notificationType ?? '';
if (notifType.contains('2')) {
final String? storedValue = await StorageService.getValue( final String? storedValue = await StorageService.getValue(
key: 'notification${AppInitializer.createNotificationId(data)}'); key: 'notification${AppInitializer.createNotificationId(data)}');
if (storedValue != null) { if (storedValue != null) {
@ -116,6 +148,26 @@ class NotificationService {
} }
} }
// Resolve the image we want on the notification.
// - `data.imageUrl` is the new content-service broadcast field (remote
// URL must be downloaded into the cache before Android can render
// it via BigPictureStyle).
// - `data.image` is the legacy widget-update field (already a local
// path).
String? bigPicturePath;
if (data.imageUrl != null && data.imageUrl!.isNotEmpty) {
bigPicturePath = await _downloadImageToTemp(data.imageUrl!);
} else if (data.image != null && data.image!.isNotEmpty) {
bigPicturePath = data.image;
}
// Pick the Android style:
// - notificationType '1' (legacy "big news" push) BigPictureStyle
// - any push with a resolved image (content broadcasts)
// BigPictureStyle so the cover image shows up
// - otherwise MessagingStyle (compact)
final useBigPicture = notifType.contains('1') || bigPicturePath != null;
final AndroidNotificationDetails androidPlatformChannelSpecifics = final AndroidNotificationDetails androidPlatformChannelSpecifics =
AndroidNotificationDetails( AndroidNotificationDetails(
'content', 'content',
@ -124,11 +176,11 @@ class NotificationService {
importance: Importance.max, importance: Importance.max,
priority: Priority.high, priority: Priority.high,
playSound: true, playSound: true,
styleInformation: data.notificationType!.contains('1') styleInformation: useBigPicture && bigPicturePath != null
? BigPictureStyleInformation( ? BigPictureStyleInformation(
FilePathAndroidBitmap(data.image ?? ''), FilePathAndroidBitmap(bigPicturePath),
contentTitle: data.title, contentTitle: data.title,
summaryText: "خبر جدید", summaryText: data.body ?? '',
) )
: MessagingStyleInformation( : MessagingStyleInformation(
Person(name: data.title), Person(name: data.title),
@ -139,17 +191,21 @@ class NotificationService {
final NotificationDetails platformChannelSpecifics = final NotificationDetails platformChannelSpecifics =
NotificationDetails(android: androidPlatformChannelSpecifics); NotificationDetails(android: androidPlatformChannelSpecifics);
// Pass the full data map (incl. id/type/link/imageUrl) through the
// payload so the onTap handler can route correctly.
final payloadJson = jsonEncode(data.toJson());
await _flutterLocalNotificationsPlugin.show( await _flutterLocalNotificationsPlugin.show(
data.notificationType!.contains('1') useBigPicture
? DateTime.now().millisecondsSinceEpoch ~/ 1000 ? DateTime.now().millisecondsSinceEpoch ~/ 1000
: AppInitializer.createNotificationId(data), : AppInitializer.createNotificationId(data),
data.title, data.title,
data.body, data.body,
platformChannelSpecifics, platformChannelSpecifics,
payload: data.toPayload().toString(), payload: payloadJson,
); );
if (data.notificationType!.contains('2')) { if (notifType.contains('2')) {
await StorageService.setValue( await StorageService.setValue(
key: 'notification${AppInitializer.createNotificationId(data)}', key: 'notification${AppInitializer.createNotificationId(data)}',
value: data.body); value: data.body);

View File

@ -1,8 +1,12 @@
import 'dart:io';
import 'package:didvan/views/widgets/didvan/text.dart'; import 'package:didvan/views/widgets/didvan/text.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:didvan/services/network/request.dart'; import 'package:didvan/services/network/request.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:universal_html/html.dart' as html; import 'package:universal_html/html.dart' as html;
class PdfViewerPage extends StatefulWidget { class PdfViewerPage extends StatefulWidget {
@ -24,6 +28,7 @@ class _PdfViewerPageState extends State<PdfViewerPage> {
bool _isLoading = true; bool _isLoading = true;
String? _errorMessage; String? _errorMessage;
late String _fullPdfUrl; late String _fullPdfUrl;
File? _localPdfFile;
@override @override
void initState() { void initState() {
@ -45,6 +50,52 @@ class _PdfViewerPageState extends State<PdfViewerPage> {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_openPdfInBrowser(); _openPdfInBrowser();
}); });
} else {
// در موبایل: PDF را به temp دانلود میکنیم و سپس با SfPdfViewer.file
// نمایش میدهیم. SfPdfViewer.network با presigned URLهای Arvan که
// query params زیاد و امضای AWS دارند گاهی fail میشود؛ دانلود
// محلی این مشکل را دور میزند.
_downloadAndOpen();
}
}
Future<void> _downloadAndOpen() async {
try {
final headers = (_fullPdfUrl.contains('didvan.com') ||
_fullPdfUrl.contains('didvan.app'))
? {'Authorization': 'Bearer ${RequestService.token}'}
: const <String, String>{};
final res = await http.get(Uri.parse(_fullPdfUrl), headers: headers);
if (res.statusCode != 200) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = 'HTTP ${res.statusCode}';
});
}
return;
}
final tempDir = await getTemporaryDirectory();
final file = File(
'${tempDir.path}/pdf_${DateTime.now().millisecondsSinceEpoch}.pdf',
);
await file.writeAsBytes(res.bodyBytes, flush: true);
if (!mounted) return;
setState(() {
_localPdfFile = file;
// isLoading remains true until SfPdfViewer's onDocumentLoaded fires
});
} catch (e) {
if (kDebugMode) print('PDF download failed: $e');
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = e.toString();
});
}
} }
} }
@ -132,20 +183,15 @@ class _PdfViewerPageState extends State<PdfViewerPage> {
), ),
body: Stack( body: Stack(
children: [ children: [
SfPdfViewer.network( if (_localPdfFile != null)
_fullPdfUrl, SfPdfViewer.file(
_localPdfFile!,
controller: _pdfViewerController, controller: _pdfViewerController,
enableDoubleTapZooming: true, enableDoubleTapZooming: true,
enableTextSelection: true, enableTextSelection: true,
canShowScrollHead: true, canShowScrollHead: true,
canShowScrollStatus: true, canShowScrollStatus: true,
pageLayoutMode: PdfPageLayoutMode.continuous, pageLayoutMode: PdfPageLayoutMode.continuous,
// فقط برای دامنهی خودی توکن میفرستیم؛ presigned S3 با هدر
// Authorization پاسخ 403 میدهد.
headers: _fullPdfUrl.contains('didvan.com') ||
_fullPdfUrl.contains('didvan.app')
? {'Authorization': 'Bearer ${RequestService.token}'}
: const <String, String>{},
onDocumentLoaded: (PdfDocumentLoadedDetails details) { onDocumentLoaded: (PdfDocumentLoadedDetails details) {
setState(() { setState(() {
_isLoading = false; _isLoading = false;