diff --git a/lib/main.dart b/lib/main.dart index abef38e..262172f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 _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 _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), diff --git a/lib/models/notification_message.dart b/lib/models/notification_message.dart index aafcbf7..f16b506 100644 --- a/lib/models/notification_message.dart +++ b/lib/models/notification_message.dart @@ -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; diff --git a/lib/services/notification/notification_service.dart b/lib/services/notification/notification_service.dart index 7c3a0c4..b4f5a41 100644 --- a/lib/services/notification/notification_service.dart +++ b/lib/services/notification/notification_service.dart @@ -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 _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 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); diff --git a/lib/views/pdf_viewer/pdf_viewer_page.dart b/lib/views/pdf_viewer/pdf_viewer_page.dart index 9149100..56569bb 100644 --- a/lib/views/pdf_viewer/pdf_viewer_page.dart +++ b/lib/views/pdf_viewer/pdf_viewer_page.dart @@ -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 { bool _isLoading = true; String? _errorMessage; late String _fullPdfUrl; + File? _localPdfFile; @override void initState() { @@ -45,6 +50,52 @@ class _PdfViewerPageState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { _openPdfInBrowser(); }); + } else { + // در موبایل: PDF را به temp دانلود می‌کنیم و سپس با SfPdfViewer.file + // نمایش می‌دهیم. SfPdfViewer.network با presigned URLهای Arvan که + // query params زیاد و امضای AWS دارند گاهی fail می‌شود؛ دانلود + // محلی این مشکل را دور می‌زند. + _downloadAndOpen(); + } + } + + Future _downloadAndOpen() async { + try { + final headers = (_fullPdfUrl.contains('didvan.com') || + _fullPdfUrl.contains('didvan.app')) + ? {'Authorization': 'Bearer ${RequestService.token}'} + : const {}; + + 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,40 +183,35 @@ class _PdfViewerPageState extends State { ), body: Stack( children: [ - SfPdfViewer.network( - _fullPdfUrl, - 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 {}, - onDocumentLoaded: (PdfDocumentLoadedDetails details) { - setState(() { - _isLoading = false; - }); - if (kDebugMode) { - print( - 'PDF loaded successfully with ${details.document.pages.count} pages'); - } - }, - onDocumentLoadFailed: (PdfDocumentLoadFailedDetails details) { - setState(() { - _isLoading = false; - _errorMessage = details.error; - }); - if (kDebugMode) { - print('PDF load failed: ${details.error}'); - print('Description: ${details.description}'); - } - }, - ), + if (_localPdfFile != null) + SfPdfViewer.file( + _localPdfFile!, + controller: _pdfViewerController, + enableDoubleTapZooming: true, + enableTextSelection: true, + canShowScrollHead: true, + canShowScrollStatus: true, + pageLayoutMode: PdfPageLayoutMode.continuous, + onDocumentLoaded: (PdfDocumentLoadedDetails details) { + setState(() { + _isLoading = false; + }); + if (kDebugMode) { + print( + 'PDF loaded successfully with ${details.document.pages.count} pages'); + } + }, + onDocumentLoadFailed: (PdfDocumentLoadFailedDetails details) { + setState(() { + _isLoading = false; + _errorMessage = details.error; + }); + if (kDebugMode) { + print('PDF load failed: ${details.error}'); + print('Description: ${details.description}'); + } + }, + ), if (_isLoading) const Center( child: CircularProgressIndicator(),