Update codebase
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// إشعار وصل إلى الجهاز — يُحفظ محلياً لأن الخادم لا يسرد شيئاً.
|
||||
class AppNotification {
|
||||
const AppNotification({
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.receivedAt,
|
||||
this.read = false,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String body;
|
||||
final DateTime receivedAt;
|
||||
final bool read;
|
||||
|
||||
AppNotification copyWith({bool? read}) => AppNotification(
|
||||
title: title,
|
||||
body: body,
|
||||
receivedAt: receivedAt,
|
||||
read: read ?? this.read,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
't': title,
|
||||
'b': body,
|
||||
'at': receivedAt.toIso8601String(),
|
||||
'r': read,
|
||||
};
|
||||
|
||||
factory AppNotification.fromJson(Map<String, dynamic> json) =>
|
||||
AppNotification(
|
||||
title: json['t'] as String? ?? '',
|
||||
body: json['b'] as String? ?? '',
|
||||
receivedAt:
|
||||
DateTime.tryParse(json['at']?.toString() ?? '') ?? DateTime.now(),
|
||||
read: json['r'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// صندوق الإشعارات — **محلّي بالكامل وعن قصد**.
|
||||
///
|
||||
/// الخادم يوفّر `POST /notifications/token` فقط ولا نقطة سرد
|
||||
/// (`backend-archive/src/modules/notifications`, docs/38 §11). فبدل شاشة
|
||||
/// فارغة تعتمد على نقطة غير موجودة، نحفظ ما يصل فعلاً عبر FCM بينما التطبيق
|
||||
/// يعمل. سيرو يعرض `NotificationPage` في الدراور
|
||||
/// (`map_menu_widget.dart:354`) — هذا مقابلها الصادق.
|
||||
///
|
||||
/// **حدّ معروف:** ما يصل والتطبيق مغلق تماماً لا يُلتقط — لا `background
|
||||
/// handler` مسجَّلاً. حين تُضاف نقطة سرد على الخادم، يُستبدل هذا المصدر
|
||||
/// بها ويبقى نفس عرض الشاشة.
|
||||
class NotificationStore {
|
||||
static const _key = 'notifications_inbox';
|
||||
|
||||
/// سقف الاحتفاظ — صندوق بلا حدّ يتضخّم بلا فائدة.
|
||||
static const _max = 50;
|
||||
|
||||
List<AppNotification> _items = const [];
|
||||
List<AppNotification> get items => _items;
|
||||
|
||||
int get unreadCount => _items.where((n) => !n.read).length;
|
||||
|
||||
Future<void> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key);
|
||||
if (raw == null || raw.isEmpty) return;
|
||||
try {
|
||||
final list = jsonDecode(raw) as List<dynamic>;
|
||||
_items = list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(AppNotification.fromJson)
|
||||
.toList(growable: false);
|
||||
} catch (_) {
|
||||
// تخزين تالف لا يمنع فتح الشاشة — يُهمل ويُكتب فوقه عند أول إشعار.
|
||||
_items = const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> add(AppNotification notification) async {
|
||||
_items = [notification, ..._items].take(_max).toList(growable: false);
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> markAllRead() async {
|
||||
if (_items.every((n) => n.read)) return;
|
||||
_items = _items.map((n) => n.copyWith(read: true)).toList(growable: false);
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
_items = const [];
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> _persist() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
_key,
|
||||
jsonEncode(_items.map((n) => n.toJson()).toList(growable: false)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,23 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../api/api_client.dart';
|
||||
import '../api/api_exception.dart';
|
||||
import 'notification_store.dart';
|
||||
|
||||
/// إشعارات FCM — **الطبقة الوحيدة التي تعرف Firebase**.
|
||||
///
|
||||
/// عقد الخادم نقطة واحدة: `POST /notifications/token { token, platform }`
|
||||
/// (docs/38 §11). لا نقطة سرد إشعارات، فلا شاشة «صندوق إشعارات» تُبنى على
|
||||
/// وهم — ما يصل يصل من النظام.
|
||||
/// (docs/38 §11). لا نقطة **سرد** إشعارات، فالصندوق محلّي: ما يصل أثناء عمل
|
||||
/// التطبيق يُحفظ في [NotificationStore] ويُعرض في `NotificationsPage` —
|
||||
/// انظر تعليق ذلك الصنف لحدود هذا الاختيار.
|
||||
///
|
||||
/// الإعداد الأصلي جاهز مسبقاً: `google-services.json` و
|
||||
/// `GoogleService-Info.plist` منقولان مع الطبقة الأصلية، و`build.gradle`
|
||||
/// يطبّق `com.google.gms.google-services` أصلاً.
|
||||
class PushService {
|
||||
PushService(this._api);
|
||||
PushService(this._api, this._store);
|
||||
|
||||
final ApiClient _api;
|
||||
final NotificationStore _store;
|
||||
|
||||
/// يُنادى **بعد** الدخول: التسجيل يربط الجهاز بالمستخدم، فقبل الجلسة
|
||||
/// لا معنى له.
|
||||
@@ -35,11 +38,30 @@ class PushService {
|
||||
// الرمز يُدوَّر من Google دورياً — بلا هذا الاشتراك تتوقّف الإشعارات
|
||||
// بصمت بعد أسابيع.
|
||||
messaging.onTokenRefresh.listen(_send);
|
||||
|
||||
// ما يصل والتطبيق مفتوح، وما فُتح التطبيق بالضغط عليه.
|
||||
FirebaseMessaging.onMessage.listen(_capture);
|
||||
FirebaseMessaging.onMessageOpenedApp.listen(_capture);
|
||||
final initial = await messaging.getInitialMessage();
|
||||
if (initial != null) await _capture(initial);
|
||||
} catch (_) {
|
||||
// غياب الإشعارات لا يمنع استعمال التطبيق — لا يُسقط الإقلاع.
|
||||
}
|
||||
}
|
||||
|
||||
/// إشعار بلا عنوان ولا نصّ (رسالة بيانات صرفة) لا يُعرض — سطر فارغ في
|
||||
/// الصندوق أسوأ من لا شيء.
|
||||
Future<void> _capture(RemoteMessage message) async {
|
||||
final title = message.notification?.title ?? '';
|
||||
final body = message.notification?.body ?? '';
|
||||
if (title.isEmpty && body.isEmpty) return;
|
||||
await _store.add(AppNotification(
|
||||
title: title,
|
||||
body: body,
|
||||
receivedAt: message.sentTime ?? DateTime.now(),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _send(String token) async {
|
||||
try {
|
||||
await _api.post<Map<String, dynamic>>(
|
||||
|
||||
Reference in New Issue
Block a user