Update codebase

This commit is contained in:
Hamza-Ayed
2026-08-05 14:12:41 +03:00
parent 58928cd258
commit 95e2e4f35d
51 changed files with 3141 additions and 390 deletions
@@ -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>>(