# دليل أداء تطبيق Kapitano — لفريق الموبايل

هذا الدليل يشرح كيف يستفيد التطبيق من الـ endpoints الجديدة، وكيف يحفظ البيانات والصور على الجهاز، وكيف يتصرف في الحالات الخاصة.

يُقرأ مع ملفين:
- `app-data-sync.md`: العقد التقني الكامل بالأمثلة.
- `claude-app-data-sync-handoff.md`: قائمة المهام المطلوبة.

## 0. لماذا هذا مهم

على شبكات الموبايل في سوريا، كلفة **الطلب الواحد** هي الأكبر، لا كلفة حجمه: اتصال وتشفير وانتظار، من 300 إلى 800 ميلي ثانية للطلب. لذلك فتح التطبيق بعشرين طلباً بطيء حتى لو كانت الردود صغيرة.

ما قيس على نسخة من بيانات الموقع الحقيقية:

| الشاشة | قبل | الآن |
|---|---|---|
| الإقلاع + الرئيسية (زائر) | 20 طلباً، 97KB | طلب واحد. ونحو **1KB** إن لم يتغير شيء |
| الإقلاع + الرئيسية (عميل) | 24 طلباً، 155KB | طلب واحد. ونحو **1KB** إن لم يتغير شيء |
| صفحة الدفع | 7 طلبات | طلب واحد |
| السلة بـ 10 منتجات | 11 طلباً (طلب لكل منتج) | طلب واحد |

---

## 1. القاعدة الذهبية: اعرض من الجهاز فوراً، ثم حدّث

لا تجعل المستخدم ينتظر الشبكة ليرى الرئيسية:

1. عند فتح التطبيق، اقرأ المجموعات المخزنة على الجهاز واعرض الرئيسية **فوراً**.
2. في نفس اللحظة أرسل `POST app/data` مع الطوابع المخزنة.
3. المجموعات التي رجعت ببيانات جديدة: خزّنها، وحدّث أجزاء الشاشة التي تغيّرت فقط.
4. المجموعات التي رجعت `null`: لا تفعل شيئاً، فما على الشاشة هو الأحدث.

في أول تثبيت لا يوجد مخزن، فاعرض هياكل رمادية (skeleton) إلى أن يصل الرد.

---

## 2. مخزن المجموعات على الجهاز

### 2.1 ماذا تخزّن لكل مجموعة

```text
key  = اسم المجموعة (sliders, featured_categories, ...)
value = {
  stamp:   "2026-10-22T09:14:03Z~3f2a9c1b7e44",   // كما وصل من السيرفر
  data:    "<نص JSON لقيمة data كما وصلت>",
  savedAt: 1729588443                              // للتشخيص فقط، لا للمقارنة
}
```

- **الطابع والبيانات في سجل واحد** يُكتب دفعة واحدة. لا يجوز أبداً أن يبقى طابع بلا بياناته.
- اكتب السجل كاملاً ثم استبدل القديم، فلا تبقى نصف كتابة إن أُغلق التطبيق أثناءها.
- استعمل Hive أو Isar أو ملفاً لكل مجموعة. **لا تستعمل SharedPreferences للبيانات الكبيرة**: تُحمَّل كلها في الذاكرة عند الإقلاع وتبطئه.
- أضف رقم إصدار للمخزن (`schemaVersion`). إن تغيّر الـ parser في نسخة جديدة من التطبيق، ارفع الرقم وامسح المخزن القديم.

### 2.2 الخوارزمية

```dart
Future<void> syncAppData(List<String> groups) async {
  final body = <String, dynamic>{'groups': groups, 'temp_user_id': tempUserId};
  for (final g in groups) {
    final entry = store.read(g);            // null إن لم يوجد أو تعذّرت قراءته
    if (entry != null && entry.stamp.isNotEmpty) {
      body['${g}_last_updated'] = entry.stamp;  // فقط مع بيانات مخزنة سليمة
    }
  }

  final res = await api.postJson('/app/data', body);   // طلب واحد
  if (res == null || res['success'] != true) {          // سيرفر قديم أو فشل شبكة
    return loadWithOldEndpoints(groups);               // المسار القديم كما هو اليوم
  }

  final data = res['data'] as Map<String, dynamic>;
  final fallback = <String>[];
  for (final g in groups) {
    if (!data.containsKey(g)) { fallback.add(g); continue; }   // لم يُجب: endpoint قديم
    final value = data[g];
    if (value == null) {
      if (store.read(g) == null) fallback.add(g);   // null بلا مخزن (لا يجب أن يحدث): أعد التحميل
      continue;                                     // غير متغير: اعرض المخزن
    }
    final stamp = (value['last_updated'] as String?) ?? '';
    store.write(g, stamp: stamp, data: jsonEncode(value['data']));
    applyToUi(g, value['data']);                    // بالـ parser القديم لنفس الـ endpoint
  }
  await loadWithOldEndpoints(fallback);             // بالتوازي، والمجموعات في _deferred منها
}
```

- **حلّل `data` بالـ parser الموجود اليوم.** مثلاً `SlidersResponse.fromJson(value['data'])`، لأن `data` هو جسم `GET sliders` نفسه حرفياً.
- رد الإقلاع الأول قد يبلغ 100 إلى 150KB. فكّ JSON خارج خيط الواجهة: `compute(jsonDecode, response.body)`، حتى لا تتقطع الحركة على الأجهزة الضعيفة.
- لا ترسل طلبي `app/data` في نفس الوقت. إن كان طلب جارياً، انتظر نتيجته بدل إرسال آخر.

---

## 3. الطابع `last_updated`: ما يضمنه السيرفر وما يفعله التطبيق

يضمن السيرفر ما يلي:
- **كل مجموعة ترجع ببيانات معها طابع غير فارغ** على الشكل `2026-10-22T09:14:03Z~3f2a9c1b7e44`.
- الطابع مبني على **محتوى** البيانات. نفس البايتات تعطي نفس الطابع، وأي تغيير مهما صغر يعطي طابعاً جديداً.
- `null` يعني أن ما عندك هو المحتوى الحالي بالضبط، فهو آمن بعد تبديل اللغة أو تبديل الحساب أو تغيّر الأسعار.
- **الفشل لا يرجع `null` أبداً.** المجموعة التي فشلت تغيب من الرد.
- `app_version` و`event_logo` ترجعان ببياناتهما **دائماً**، حتى مع طابع مطابق، ليبقى فحص التحديث الإجباري صحيحاً.
- إن مُسح كاش السيرفر يتغير الجزء الزمني من الطوابع، فتنزل كل مجموعة مرة واحدة ثم تعود `null`.

قواعد التطبيق:
- **الطابع نص معتم:** يُخزن ويُعاد كما هو. لا تحلله ولا تقارنه كتاريخ ولا تستعمل ساعة الجهاز. الجزء الزمني للتشخيص فقط.
- إن وصل طابع فارغ (سيرفر أقدم مثلاً): خزّن البيانات بلا طابع، ولا ترسل لها طابعاً في المرة القادمة.

---

## 4. الحالات الخاصة: جدول التصرف

| الحالة | ما يفعله التطبيق |
|---|---|
| تثبيت جديد، أو المستخدم مسح بيانات التطبيق | لا مخزن ولا طوابع، فتصل كل البيانات. |
| النظام مسح كاش الصور فقط | البيانات سليمة، والصور تُحمّل من جديد عند عرضها (§5). |
| مخزن تالف أو فشل تحليل مجموعة | احذف تلك المجموعة وحدها، واطلبها بلا طابع. |
| رجع `null` لمجموعة لا توجد بياناتها | لا يجب أن يحدث إن اتبعت §2. احتياطاً: اطلب الـ endpoint القديم لها. |
| المفتاح غائب، أو الاسم في `_deferred` | استدعِ الـ endpoint القديم لتلك المجموعة. |
| `success` ليس `true`، أو `Invalid Route`، أو انقطاع الشبكة | اعرض المخزن، واستعمل المسار القديم إن لزم. |
| تبديل اللغة | نفس الطلب ونفس الطوابع، والسيرفر يرسل ما تغيّر. اختيارياً: مخزن لكل لغة حتى لا تُحمَّل البيانات من جديد عند العودة للغة السابقة. |
| تسجيل الخروج أو تبديل الحساب | امسح مجموعات الحساب: `cart_count`، `unread_notifications`، `order_rating_pending`، `profile_counters`، `addresses`. |
| نسخة تطبيق جديدة غيّرت الـ parsers | ارفع `schemaVersion` ليُمسح المخزن القديم. |
| ساعة الجهاز خاطئة | لا تأثير، فلا مقارنة تواريخ في أي مكان. |
| مسح كاش السيرفر أو إعادة تشغيله | طوابع جديدة، وتحميل كل مجموعة مرة واحدة. لا شيء تفعله. |

---

## 5. الصور: لا تحمّل صورة مرتين

### 5.1 حقائق من السيرفر

- **كل صورة تُرفع تأخذ اسماً عشوائياً جديداً** من 40 حرفاً، ولا يُعاد كتابة ملف في مكانه. فالرابط نفسه هو الصورة نفسها دائماً.
- **تعديل صورة فئة أو بانر يعطي رابطاً جديداً**، لأنه صورة جديدة برقم جديد. فالتطبيق لا يحتاج أن يسأل "هل تغيّرت الصورة؟". إن تغيّر الرابط فهي صورة جديدة، وإن لم يتغير فهي نفسها.
- السيرفر يرسل للصورة `Cache-Control: max-age=2678400`، أي 31 يوماً (منذ 2026-10-22). خلالها يعرض `cached_network_image` الصورة من الجهاز دون أي طلب. بعدها يسأل بـ `If-Modified-Since`، فيرد السيرفر `304 Not Modified` بلا جسم (بضع مئات من البايتات).
- بعض صور منتجات الشركاء على سيرفرات الشركاء. القاعدة نفسها بالرابط، لكن اترك لها التحقق الافتراضي.

### 5.2 الإعداد في Flutter

- استعمل `cached_network_image`، وهو يعمل فوق `flutter_cache_manager` ويحفظ على القرص بالرابط. **لا تستعمل `Image.network`** لصور الكتالوج، فهي تعيد التحميل في كل تشغيل.
- خصص مدير كاش للكتالوج بسعة أكبر من الافتراضي (200 ملف فقط):

```dart
final catalogImages = CacheManager(Config(
  'catalogImages',
  stalePeriod: const Duration(days: 90),   // تُحذف إن لم تُعرض 90 يوماً
  maxNrOfCacheObjects: 3000,
));

CachedNetworkImage(
  imageUrl: url,
  cacheManager: catalogImages,
  memCacheWidth: (displayWidth * devicePixelRatio).round(), // فك الصورة بحجم العرض لا بحجمها الأصلي
  placeholder: (_, __) => const ColoredBox(color: Color(0xFFF2F2F2)),
  errorWidget: (_, __, ___) => const Icon(Icons.image_not_supported_outlined),
);
```

- **`memCacheWidth`/`cacheWidth` مهمان جداً في شبكات المنتجات.** صورة 1200px معروضة بعرض 150px تستهلك ذاكرة أكبر بنحو 64 مرة إن فُكّت بحجمها الكامل.
- لا تمسح كاش الصور عند تسجيل الخروج. الصور عامة وليست بيانات حساب.

### 5.3 صور الفئات: تُحفظ أول مرة وتُستبدل عند التحديث

الهدف: شاشة الفئات تفتح فوراً حتى بلا إنترنت، ولا تُحمّل صورة فئة مرتين.

1. عندما تصل `featured_categories` أو `top_categories` **ببيانات جديدة** (لا `null`):
   - استخرج روابط الصور: `icon` و`banner` و`cover_image`.
   - حمّل الناقص منها في الخلفية:

     ```dart
     for (final url in newUrls) {
       if (await catalogImages.getFileFromCache(url) == null) {
         unawaited(catalogImages.downloadFile(url));   // مرة واحدة فقط
       }
     }
     ```

   - الروابط التي كانت في القائمة القديمة واختفت من الجديدة احذفها لتوفير المساحة: `catalogImages.removeFile(oldUrl)`.
2. إن رجعت `null`: لا شيء. الصور على الجهاز من قبل.
3. **"تُستبدل عند التحديث" تحدث تلقائياً.** المشرف غيّر صورة الفئة، فتغيّر رابطها، فتغيّر طابع المجموعة، فوصل الرابط الجديد إلى التطبيق، فحُمّلت الصورة الجديدة وحُذفت القديمة.
4. **إن مسح النظام كاش الصور:** في الإقلاع التالي، الحلقة نفسها في الخطوة 1 (`getFileFromCache` ثم `downloadFile`) تعيد تحميل الناقص فقط. شغّلها أيضاً عندما ترجع المجموعة `null`، فهي رخيصة جداً لأنها فحص محلي.

البانرات والسلايدر بنفس الطريقة: حمّل مسبقاً أول صورتين فقط، والباقي عند العرض.

---

## 6. الشبكة

- **عميل HTTP واحد مشترك** في كل التطبيق، بنسخة واحدة من `http.Client`، حتى يُعاد استعمال الاتصال (keep-alive) ولا يتكرر التشفير مع كل طلب.
- **أبقِ ضغط gzip مفعّلاً.** `dart:io` يرسل `Accept-Encoding: gzip` ويفكّه تلقائياً، فلا تعطّله.
- **المهلة:**
  - 10 ثوانٍ للاتصال و20 ثانية للرد.
  - أعد المحاولة بتأخير متزايد لطلبات القراءة فقط.
  - **لا تُعِد تلقائياً طلبات الكتابة**: الإضافة للسلة، الطلب، الدفع.
- **البحث:**
  - انتظر 300 إلى 400 ميلي ثانية بعد آخر حرف قبل الإرسال.
  - ألغِ الطلب السابق عند كتابة حرف جديد.
- **لا تطلب نفس الشيء مرتين في نفس الوقت.** طلبان متطابقان جاريان يُدمجان في واحد.
- **الصفحات (pagination):**
  - الصفحة الأولى تأتي من `app/data`.
  - اطلب الصفحة التالية عند الوصول إلى نحو 75% من القائمة، لا عند نهايتها.
  - لا تُعد تحميل الصفحة الأولى عند الرجوع للشاشة.
- **العودة من الخلفية:** إن مضى أكثر من 5 دقائق على آخر مزامنة، أرسل `app/data` بالطوابع، وهو طلب صغير.
- **السحب للتحديث:** نفس الطلب بالطوابع.

---

## 7. توفير الطلبات في السلة والمنتج والدفع

| المكان | قبل | الآن |
|---|---|---|
| السلة | `POST carts` ثم `products/{slug}` لكل منتج لمعرفة الخصم | `POST carts` وحده: كل سطر فيه `has_discount` و`discount` و`stroked_price` و`main_price` |
| شارة السلة بعد الإضافة أو التعديل أو الحذف | طلب `cart-count` إضافي | `cart_count` في نفس الرد |
| صفحة المنتج | `products/{slug}/{id}` ثم `POST carts` | `products/{slug}/{id}?with=cart`، وفيه `in_cart` و`cart_count` |
| صفحة الدفع | 7 طلبات متتالية | `POST checkout/overview` واحد |

إن غاب حقل جديد من رد ما (سيرفر أقدم)، استعمل الطلب القديم.

---

## 8. القياس قبل الإصدار

أضف في نسخة التطوير عدّاداً للطلبات لكل شاشة، والحجم المستلم. الأهداف:

| الشاشة | الهدف |
|---|---|
| الإقلاع + الرئيسية | طلب واحد، يرجع بضع مئات من البايتات إن لم يتغير شيء |
| السلة | طلب واحد |
| صفحة المنتج | طلب واحد، ثم طلب السعر عند اختيار متغير |
| صفحة الدفع | طلب واحد، ثم طلبات التعديل فقط |
| فتح شاشة الفئات مرة ثانية | صفر طلبات صور |

## 9. قائمة تحقق

- [ ] الرئيسية تظهر من المخزن قبل وصول الشبكة.
- [ ] الطابع والبيانات يُكتبان معاً، ولا طابع يُرسل بلا بيانات.
- [ ] `null` يعرض المخزن، والمفتاح الغائب يستدعي الـ endpoint القديم.
- [ ] `Invalid Route` أو فشل الشبكة يرجع للمسار القديم.
- [ ] تسجيل الخروج يمسح مجموعات الحساب فقط.
- [ ] كل صور الكتالوج عبر `CachedNetworkImage` بمدير كاش بسعة كافية و`memCacheWidth`.
- [ ] صور الفئات تُحمّل مسبقاً عند تغيّر القائمة، وتعرض بلا إنترنت.
- [ ] السلة لا تطلب `products/{slug}`.
- [ ] صفحة الدفع طلب واحد.
