build local notification with downloaded image; PDF via local file
This commit is contained in:
parent
d5a6007f7d
commit
339499501f
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue