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:bot_toast/bot_toast.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.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 {
runZonedGuarded(
() 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) {
await FirebaseApi().initNotification().timeout(
const Duration(seconds: 5),

View File

@ -6,6 +6,9 @@ class NotificationMessage {
String? type;
String? link;
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? groupKey;
String? userId;
@ -18,6 +21,7 @@ class NotificationMessage {
this.type,
this.link,
this.image,
this.imageUrl,
this.photo,
this.groupKey,
this.userId,
@ -31,6 +35,7 @@ class NotificationMessage {
type = json['type'];
link = json['link'];
image = json['image'];
imageUrl = json['imageUrl'];
photo = json['photo'];
groupKey = json['groupKey'];
userId = json['userId'];
@ -45,6 +50,7 @@ class NotificationMessage {
data['type'] = type;
data['link'] = link;
data['image'] = image;
data['imageUrl'] = imageUrl;
data['photo'] = photo;
data['groupKey'] = groupKey;
data['userId'] = userId;
@ -60,6 +66,7 @@ class NotificationMessage {
data['type'] = type ?? '';
data['link'] = link ?? '';
data['image'] = image ?? '';
data['imageUrl'] = imageUrl ?? '';
data['photo'] = photo ?? '';
data['userId'] = userId ?? '';
return data;

View File

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_local_notifications/flutter_local_notifications.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:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
class NotificationService {
static final FlutterLocalNotificationsPlugin
@ -89,7 +92,9 @@ class NotificationService {
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) {
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 {
if (data.notificationType!.contains('2')) {
final notifType = data.notificationType ?? '';
if (notifType.contains('2')) {
final String? storedValue = await StorageService.getValue(
key: 'notification${AppInitializer.createNotificationId(data)}');
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 =
AndroidNotificationDetails(
'content',
@ -124,11 +176,11 @@ class NotificationService {
importance: Importance.max,
priority: Priority.high,
playSound: true,
styleInformation: data.notificationType!.contains('1')
styleInformation: useBigPicture && bigPicturePath != null
? BigPictureStyleInformation(
FilePathAndroidBitmap(data.image ?? ''),
FilePathAndroidBitmap(bigPicturePath),
contentTitle: data.title,
summaryText: "خبر جدید",
summaryText: data.body ?? '',
)
: MessagingStyleInformation(
Person(name: data.title),
@ -139,17 +191,21 @@ class NotificationService {
final NotificationDetails platformChannelSpecifics =
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(
data.notificationType!.contains('1')
useBigPicture
? DateTime.now().millisecondsSinceEpoch ~/ 1000
: AppInitializer.createNotificationId(data),
data.title,
data.body,
platformChannelSpecifics,
payload: data.toPayload().toString(),
payload: payloadJson,
);
if (data.notificationType!.contains('2')) {
if (notifType.contains('2')) {
await StorageService.setValue(
key: 'notification${AppInitializer.createNotificationId(data)}',
value: data.body);

View File

@ -1,8 +1,12 @@
import 'dart:io';
import 'package:didvan/views/widgets/didvan/text.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:flutter/foundation.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;
class PdfViewerPage extends StatefulWidget {
@ -24,6 +28,7 @@ class _PdfViewerPageState extends State<PdfViewerPage> {
bool _isLoading = true;
String? _errorMessage;
late String _fullPdfUrl;
File? _localPdfFile;
@override
void initState() {
@ -45,6 +50,52 @@ class _PdfViewerPageState extends State<PdfViewerPage> {
WidgetsBinding.instance.addPostFrameCallback((_) {
_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(
children: [
SfPdfViewer.network(
_fullPdfUrl,
if (_localPdfFile != null)
SfPdfViewer.file(
_localPdfFile!,
controller: _pdfViewerController,
enableDoubleTapZooming: true,
enableTextSelection: true,
canShowScrollHead: true,
canShowScrollStatus: true,
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) {
setState(() {
_isLoading = false;